Execute all admin operations from the replication queue thread pool

Before this change, the delete-project and update-head operations were
executed from the calling Gerrit core thread while the create-project
was executed in the context of the PushOne operation which is run from
the replication queue.

Tasks executed from the replication queue are created from an additional
child injector which provides more bindings, among others the
RemoteConfig. Having access to RemoteConfig from an admin task may be
necessary in order to retrieve HTTP credentials, like the PushOne task
does. This will be necessary when we implement admin operations over
the HTTP/REST protocol.

Change-Id: Iae608aa98474d6405b26bbae2508183c2d0cb212
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
index 6ed438a..8c249be 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -13,9 +13,11 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.replication;
 
+import com.google.common.collect.Multimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.FileUtil;
 import com.google.gerrit.extensions.annotations.PluginData;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
@@ -23,7 +25,9 @@
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.transport.URIish;
 
 @Singleton
 public class AutoReloadConfigDecorator implements ReplicationConfig {
@@ -70,6 +74,13 @@
     return currentConfig.getDestinations(filterType);
   }
 
+  @Override
+  public synchronized Multimap<Destination, URIish> getURIs(
+      Optional<String> remoteName, Project.NameKey projectName, FilterType filterType) {
+    reloadIfNeeded();
+    return currentConfig.getURIs(remoteName, projectName, filterType);
+  }
+
   private void reloadIfNeeded() {
     try {
       if (isAutoReload()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
new file mode 100644
index 0000000..ba740fa
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/CreateProjectTask.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
+import java.util.Optional;
+import org.eclipse.jgit.transport.RemoteConfig;
+import org.eclipse.jgit.transport.URIish;
+
+public class CreateProjectTask {
+  interface Factory {
+    CreateProjectTask create(Project.NameKey project, String head);
+  }
+
+  private final RemoteConfig config;
+  private final ReplicationConfig replicationConfig;
+  private final AdminApiFactory adminApiFactory;
+  private final NameKey project;
+  private final String head;
+
+  @Inject
+  CreateProjectTask(
+      RemoteConfig config,
+      ReplicationConfig replicationConfig,
+      AdminApiFactory adminApiFactory,
+      @Assisted Project.NameKey project,
+      @Assisted String head) {
+    this.config = config;
+    this.replicationConfig = replicationConfig;
+    this.adminApiFactory = adminApiFactory;
+    this.project = project;
+    this.head = head;
+  }
+
+  public boolean create() {
+    return replicationConfig
+        .getURIs(Optional.of(config.getName()), project, FilterType.PROJECT_CREATION)
+        .values()
+        .stream()
+        .map(u -> createProject(u, project, head))
+        .reduce(true, (a, b) -> a && b);
+  }
+
+  private boolean createProject(URIish replicateURI, Project.NameKey projectName, String head) {
+    Optional<AdminApi> adminApi = adminApiFactory.create(replicateURI);
+    if (adminApi.isPresent() && adminApi.get().createProject(projectName, head)) {
+      return true;
+    }
+
+    repLog.warn("Cannot create new project {} on remote site {}.", projectName, replicateURI);
+    return false;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
new file mode 100644
index 0000000..74c19a1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DeleteProjectTask.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
+import org.eclipse.jgit.transport.URIish;
+
+public class DeleteProjectTask implements Runnable {
+  interface Factory {
+    DeleteProjectTask create(URIish replicateURI, Project.NameKey project);
+  }
+
+  private final AdminApiFactory adminApiFactory;
+  private final URIish replicateURI;
+  private final Project.NameKey project;
+
+  @Inject
+  DeleteProjectTask(
+      AdminApiFactory adminApiFactory,
+      @Assisted URIish replicateURI,
+      @Assisted Project.NameKey project) {
+    this.adminApiFactory = adminApiFactory;
+    this.replicateURI = replicateURI;
+    this.project = project;
+  }
+
+  @Override
+  public void run() {
+    Optional<AdminApi> adminApi = adminApiFactory.create(replicateURI);
+    if (adminApi.isPresent()) {
+      adminApi.get().deleteProject(project);
+      return;
+    }
+
+    repLog.warn("Cannot delete project {} on remote site {}.", project, replicateURI);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
index b69bab6..cece68d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -15,6 +15,7 @@
 package com.googlesource.gerrit.plugins.replication;
 
 import static com.googlesource.gerrit.plugins.replication.PushResultProcessing.resolveNodeName;
+import static com.googlesource.gerrit.plugins.replication.ReplicationFileBasedConfig.replaceName;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.NON_EXISTING;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
@@ -85,6 +86,8 @@
   private final Map<URIish, PushOne> pending = new HashMap<>();
   private final Map<URIish, PushOne> inFlight = new HashMap<>();
   private final PushOne.Factory opFactory;
+  private final DeleteProjectTask.Factory deleteProjectFactory;
+  private final UpdateHeadTask.Factory updateHeadFactory;
   private final GitRepositoryManager gitManager;
   private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> userProvider;
@@ -159,6 +162,10 @@
                 bind(Destination.class).toInstance(Destination.this);
                 bind(RemoteConfig.class).toInstance(config.getRemoteConfig());
                 install(new FactoryModuleBuilder().build(PushOne.Factory.class));
+                install(new FactoryModuleBuilder().build(CreateProjectTask.Factory.class));
+                install(new FactoryModuleBuilder().build(DeleteProjectTask.Factory.class));
+                install(new FactoryModuleBuilder().build(UpdateHeadTask.Factory.class));
+                bind(AdminApiFactory.class);
               }
 
               @Provides
@@ -187,6 +194,8 @@
             });
 
     opFactory = child.getInstance(PushOne.Factory.class);
+    deleteProjectFactory = child.getInstance(DeleteProjectTask.Factory.class);
+    updateHeadFactory = child.getInstance(UpdateHeadTask.Factory.class);
     threadScoper = child.getInstance(PerThreadRequestScope.Scoper.class);
   }
 
@@ -383,6 +392,14 @@
     }
   }
 
+  void scheduleDeleteProject(URIish uri, Project.NameKey project) {
+    pool.schedule(deleteProjectFactory.create(uri, project), 0, TimeUnit.SECONDS);
+  }
+
+  void scheduleUpdateHead(URIish uri, Project.NameKey project, String newHead) {
+    pool.schedule(updateHeadFactory.create(uri, project, newHead), 0, TimeUnit.SECONDS);
+  }
+
   private void addRef(PushOne e, String ref) {
     e.addRef(ref);
     postReplicationScheduledEvent(e, ref);
@@ -578,8 +595,7 @@
         } else if (!remoteNameStyle.equals("slash")) {
           repLog.debug("Unknown remoteNameStyle: {}, falling back to slash", remoteNameStyle);
         }
-        String replacedPath =
-            ReplicationQueue.replaceName(uri.getPath(), name, isSingleProjectMatch());
+        String replacedPath = replaceName(uri.getPath(), name, isSingleProjectMatch());
         if (replacedPath != null) {
           uri = uri.setPath(replacedPath);
           r.add(uri);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index c4fcaf3..5257384 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -94,7 +94,6 @@
   private final RemoteConfig config;
   private final CredentialsProvider credentialsProvider;
   private final PerThreadRequestScope.Scoper threadScoper;
-  private final ReplicationQueue replicationQueue;
 
   private final Project.NameKey projectName;
   private final URIish uri;
@@ -112,6 +111,7 @@
   private final long createdAt;
   private final ReplicationMetrics metrics;
   private final ProjectCache projectCache;
+  private final CreateProjectTask.Factory createProjectFactory;
   private final AtomicBoolean canceledWhileRunning;
 
   @Inject
@@ -122,11 +122,11 @@
       RemoteConfig c,
       CredentialsFactory cpFactory,
       PerThreadRequestScope.Scoper ts,
-      ReplicationQueue rq,
       IdGenerator ig,
       ReplicationStateListener sl,
       ReplicationMetrics m,
       ProjectCache pc,
+      CreateProjectTask.Factory cpf,
       @Assisted Project.NameKey d,
       @Assisted URIish u) {
     gitManager = grm;
@@ -135,7 +135,6 @@
     config = c;
     credentialsProvider = cpFactory.create(c.getName());
     threadScoper = ts;
-    replicationQueue = rq;
     projectName = d;
     uri = u;
     lockRetryCount = 0;
@@ -145,6 +144,7 @@
     createdAt = System.nanoTime();
     metrics = m;
     projectCache = pc;
+    createProjectFactory = cpf;
     canceledWhileRunning = new AtomicBoolean(false);
     maxRetries = p.getMaxRetries();
   }
@@ -397,8 +397,7 @@
     if (pool.isCreateMissingRepos()) {
       try {
         Ref head = git.exactRef(Constants.HEAD);
-        if (replicationQueue.createProject(
-            config.getName(), projectName, head != null ? getName(head) : null)) {
+        if (createProject(projectName, head != null ? getName(head) : null)) {
           repLog.warn("Missing repository created; retry replication to {}", uri);
           pool.reschedule(this, Destination.RetryReason.REPOSITORY_MISSING);
         } else {
@@ -415,6 +414,10 @@
     }
   }
 
+  private boolean createProject(Project.NameKey project, String head) {
+    return createProjectFactory.create(project, head).create();
+  }
+
   private String getName(Ref ref) {
     Ref target = ref;
     while (target.isSymbolic()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
index 9693e2d..cd1c561 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfig.java
@@ -13,9 +13,13 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.replication;
 
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
 import com.google.gerrit.server.git.WorkQueue;
 import java.nio.file.Path;
 import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.transport.URIish;
 
 public interface ReplicationConfig {
 
@@ -27,6 +31,9 @@
 
   List<Destination> getDestinations(FilterType filterType);
 
+  Multimap<Destination, URIish> getURIs(
+      Optional<String> remoteName, NameKey projectName, FilterType filterType);
+
   boolean isReplicateAllOnPluginStart();
 
   boolean isDefaultForceUpdate();
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java
index 3e8781e..8e072c6 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationFileBasedConfig.java
@@ -13,13 +13,21 @@
 // limitations under the License.
 package com.googlesource.gerrit.plugins.replication;
 
+import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isGerrit;
+import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isSSH;
+import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.HashMultimap;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.SetMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.annotations.PluginData;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
@@ -30,6 +38,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.function.Predicate;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -149,6 +158,77 @@
     return dest.build();
   }
 
+  @Override
+  public Multimap<Destination, URIish> getURIs(
+      Optional<String> remoteName, Project.NameKey projectName, FilterType filterType) {
+    if (getDestinations(filterType).isEmpty()) {
+      return ImmutableMultimap.of();
+    }
+
+    SetMultimap<Destination, URIish> uris = HashMultimap.create();
+    for (Destination config : getDestinations(filterType)) {
+      if (!config.wouldPushProject(projectName)) {
+        continue;
+      }
+
+      if (remoteName.isPresent() && !config.getRemoteConfigName().equals(remoteName.get())) {
+        continue;
+      }
+
+      boolean adminURLUsed = false;
+
+      for (String url : config.getAdminUrls()) {
+        if (Strings.isNullOrEmpty(url)) {
+          continue;
+        }
+
+        URIish uri;
+        try {
+          uri = new URIish(url);
+        } catch (URISyntaxException e) {
+          repLog.warn("adminURL '{}' is invalid: {}", url, e.getMessage());
+          continue;
+        }
+
+        if (!isGerrit(uri)) {
+          String path =
+              replaceName(uri.getPath(), projectName.get(), config.isSingleProjectMatch());
+          if (path == null) {
+            repLog.warn("adminURL {} does not contain ${name}", uri);
+            continue;
+          }
+
+          uri = uri.setPath(path);
+          if (!isSSH(uri)) {
+            repLog.warn("adminURL '{}' is invalid: only SSH is supported", uri);
+            continue;
+          }
+        }
+        uris.put(config, uri);
+        adminURLUsed = true;
+      }
+
+      if (!adminURLUsed) {
+        for (URIish uri : config.getURIs(projectName, "*")) {
+          uris.put(config, uri);
+        }
+      }
+    }
+    return uris;
+  }
+
+  static String replaceName(String in, String name, boolean keyIsOptional) {
+    String key = "${name}";
+    int n = in.indexOf(key);
+    if (0 <= n) {
+      return in.substring(0, n) + name + in.substring(n + key.length());
+    }
+    if (keyIsOptional) {
+      return in;
+    }
+    return null;
+  }
+
   /* (non-Javadoc)
    * @see com.googlesource.gerrit.plugins.replication.ReplicationConfig#isReplicateAllOnPluginStart()
    */
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
index 2779620..5a73268 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -66,7 +66,5 @@
     EventTypes.register(RefReplicationDoneEvent.TYPE, RefReplicationDoneEvent.class);
     EventTypes.register(ReplicationScheduledEvent.TYPE, ReplicationScheduledEvent.class);
     bind(SshSessionFactory.class).toProvider(ReplicationSshSessionFactoryProvider.class);
-
-    bind(AdminApiFactory.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
index f73530d..1b4e7d2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
@@ -14,11 +14,6 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isGerrit;
-import static com.googlesource.gerrit.plugins.replication.AdminApiFactory.isSSH;
-
-import com.google.common.base.Strings;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
@@ -30,11 +25,7 @@
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.PushResultProcessing.GitUpdateProcessing;
 import com.googlesource.gerrit.plugins.replication.ReplicationConfig.FilterType;
-import java.net.URISyntaxException;
-import java.util.Collections;
-import java.util.HashSet;
 import java.util.Optional;
-import java.util.Set;
 import org.eclipse.jgit.transport.URIish;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -50,22 +41,9 @@
 
   private final ReplicationStateListener stateLog;
 
-  static String replaceName(String in, String name, boolean keyIsOptional) {
-    String key = "${name}";
-    int n = in.indexOf(key);
-    if (0 <= n) {
-      return in.substring(0, n) + name + in.substring(n + key.length());
-    }
-    if (keyIsOptional) {
-      return in;
-    }
-    return null;
-  }
-
   private final WorkQueue workQueue;
   private final DynamicItem<EventDispatcher> dispatcher;
   private final ReplicationConfig config;
-  private final AdminApiFactory adminApiFactory;
   private final ReplicationState.Factory replicationStateFactory;
   private final EventsStorage eventsStorage;
   private volatile boolean running;
@@ -73,7 +51,6 @@
   @Inject
   ReplicationQueue(
       WorkQueue wq,
-      AdminApiFactory aaf,
       ReplicationConfig rc,
       DynamicItem<EventDispatcher> dis,
       ReplicationStateListener sl,
@@ -83,7 +60,6 @@
     dispatcher = dis;
     config = rc;
     stateLog = sl;
-    adminApiFactory = aaf;
     replicationStateFactory = rsf;
     eventsStorage = es;
   }
@@ -159,121 +135,20 @@
 
   @Override
   public void onProjectDeleted(ProjectDeletedListener.Event event) {
-    Project.NameKey projectName = new Project.NameKey(event.getProjectName());
-    for (URIish uri : getURIs(null, projectName, FilterType.PROJECT_DELETION)) {
-      deleteProject(uri, projectName);
-    }
+    Project.NameKey p = new Project.NameKey(event.getProjectName());
+    config.getURIs(Optional.empty(), p, FilterType.PROJECT_DELETION)
+        .entries()
+        .stream()
+        .forEach(e -> e.getKey().scheduleDeleteProject(e.getValue(), p));
   }
 
   @Override
   public void onHeadUpdated(HeadUpdatedListener.Event event) {
-    Project.NameKey project = new Project.NameKey(event.getProjectName());
-    for (URIish uri : getURIs(null, project, FilterType.ALL)) {
-      updateHead(uri, project, event.getNewHeadName());
-    }
-  }
-
-  private Set<URIish> getURIs(
-      @Nullable String remoteName, Project.NameKey projectName, FilterType filterType) {
-    if (config.getDestinations(filterType).isEmpty()) {
-      return Collections.emptySet();
-    }
-    if (!running) {
-      repLog.error("Replication plugin did not finish startup before event");
-      return Collections.emptySet();
-    }
-
-    Set<URIish> uris = new HashSet<>();
-    for (Destination config : this.config.getDestinations(filterType)) {
-      if (!config.wouldPushProject(projectName)) {
-        continue;
-      }
-
-      if (remoteName != null && !config.getRemoteConfigName().equals(remoteName)) {
-        continue;
-      }
-
-      boolean adminURLUsed = false;
-
-      for (String url : config.getAdminUrls()) {
-        if (Strings.isNullOrEmpty(url)) {
-          continue;
-        }
-
-        URIish uri;
-        try {
-          uri = new URIish(url);
-        } catch (URISyntaxException e) {
-          repLog.warn("adminURL '{}' is invalid: {}", url, e.getMessage());
-          continue;
-        }
-
-        if (!isGerrit(uri)) {
-          String path =
-              replaceName(uri.getPath(), projectName.get(), config.isSingleProjectMatch());
-          if (path == null) {
-            repLog.warn("adminURL {} does not contain ${name}", uri);
-            continue;
-          }
-
-          uri = uri.setPath(path);
-          if (!isSSH(uri)) {
-            repLog.warn("adminURL '{}' is invalid: only SSH is supported", uri);
-            continue;
-          }
-        }
-        uris.add(uri);
-        adminURLUsed = true;
-      }
-
-      if (!adminURLUsed) {
-        for (URIish uri : config.getURIs(projectName, "*")) {
-          uris.add(uri);
-        }
-      }
-    }
-    return uris;
-  }
-
-  public boolean createProject(String remoteName, Project.NameKey project, String head) {
-    boolean success = true;
-    for (URIish uri : getURIs(remoteName, project, FilterType.PROJECT_CREATION)) {
-      success &= createProject(uri, project, head);
-    }
-    return success;
-  }
-
-  private boolean createProject(URIish replicateURI, Project.NameKey projectName, String head) {
-    Optional<AdminApi> adminApi = adminApiFactory.create(replicateURI);
-    if (adminApi.isPresent() && adminApi.get().createProject(projectName, head)) {
-      return true;
-    }
-
-    warnCannotPerform("create new project", replicateURI);
-    return false;
-  }
-
-  private void deleteProject(URIish replicateURI, Project.NameKey projectName) {
-    Optional<AdminApi> adminApi = adminApiFactory.create(replicateURI);
-    if (adminApi.isPresent()) {
-      adminApi.get().deleteProject(projectName);
-      return;
-    }
-
-    warnCannotPerform("delete project", replicateURI);
-  }
-
-  private void updateHead(URIish replicateURI, Project.NameKey projectName, String newHead) {
-    Optional<AdminApi> adminApi = adminApiFactory.create(replicateURI);
-    if (adminApi.isPresent()) {
-      adminApi.get().updateHead(projectName, newHead);
-      return;
-    }
-
-    warnCannotPerform("update HEAD of project", replicateURI);
-  }
-
-  private void warnCannotPerform(String op, URIish uri) {
-    repLog.warn("Cannot {} on remote site {}.", op, uri);
+    Project.NameKey p = new Project.NameKey(event.getProjectName());
+    config
+        .getURIs(Optional.empty(), p, FilterType.ALL)
+        .entries()
+        .stream()
+        .forEach(e -> e.getKey().scheduleUpdateHead(e.getValue(), p, event.getNewHeadName()));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java b/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java
new file mode 100644
index 0000000..e91a3f5
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/UpdateHeadTask.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replication;
+
+import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+
+import java.util.Optional;
+
+import org.eclipse.jgit.transport.URIish;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class UpdateHeadTask implements Runnable {
+  private final AdminApiFactory adminApiFactory;
+  private final URIish replicateURI;
+  private final Project.NameKey project;
+  private final String newHead;
+
+  interface Factory {
+    UpdateHeadTask create(URIish uri, Project.NameKey project, String newHead);
+  }
+
+  @Inject
+  UpdateHeadTask(AdminApiFactory adminApiFactory,
+      @Assisted URIish replicateURI,
+      @Assisted Project.NameKey project,
+      @Assisted String newHead) {
+    this.adminApiFactory = adminApiFactory;
+    this.replicateURI = replicateURI;
+    this.project = project;
+    this.newHead = newHead;
+  }
+
+  @Override
+  public void run() {
+    Optional<AdminApi> adminApi = adminApiFactory.create(replicateURI);
+    if (adminApi.isPresent()) {
+      adminApi.get().updateHead(project, newHead);
+      return;
+    }
+
+    repLog.warn("Cannot update HEAD of project {} on remote site {}.", project, replicateURI);
+  }
+}