Merge "ReplicationTasksStorage: Handle DirectoryIteratorExceptions (part 2)"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigParser.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigParser.java
index 66251a5..29ea706 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigParser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ConfigParser.java
@@ -14,24 +14,12 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import java.net.URISyntaxException;
-import java.util.Collections;
 import java.util.List;
-import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.transport.RefSpec;
-import org.eclipse.jgit.transport.RemoteConfig;
-import org.eclipse.jgit.transport.URIish;
 
-public class ConfigParser {
-
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+/** Parser for parsing {@link Config} to a collection of {@link RemoteConfiguration} objects */
+public interface ConfigParser {
 
   /**
    * parse the new replication config
@@ -40,70 +28,5 @@
    * @return List of parsed {@link RemoteConfiguration}
    * @throws ConfigInvalidException if the new configuration is not valid.
    */
-  public List<RemoteConfiguration> parseRemotes(Config config) throws ConfigInvalidException {
-
-    if (config.getSections().isEmpty()) {
-      logger.atWarning().log("Replication config does not exist or it's empty; not replicating");
-      return Collections.emptyList();
-    }
-
-    boolean defaultForceUpdate = config.getBoolean("gerrit", "defaultForceUpdate", false);
-
-    ImmutableList.Builder<RemoteConfiguration> confs = ImmutableList.builder();
-    for (RemoteConfig c : allRemotes(config)) {
-      if (c.getURIs().isEmpty()) {
-        continue;
-      }
-
-      if (!c.getFetchRefSpecs().isEmpty()) {
-        repLog.atInfo().log("Ignore '%s' endpoint: not a 'push' target", c.getName());
-        continue;
-      }
-
-      // If destination for push is not set assume equal to source.
-      for (RefSpec ref : c.getPushRefSpecs()) {
-        if (ref.getDestination() == null) {
-          ref.setDestination(ref.getSource());
-        }
-      }
-
-      if (c.getPushRefSpecs().isEmpty()) {
-        c.addPushRefSpec(
-            new RefSpec()
-                .setSourceDestination("refs/*", "refs/*")
-                .setForceUpdate(defaultForceUpdate));
-      }
-
-      DestinationConfiguration destinationConfiguration = new DestinationConfiguration(c, config);
-
-      if (!destinationConfiguration.isSingleProjectMatch()) {
-        for (URIish u : c.getURIs()) {
-          if (u.getPath() == null || !u.getPath().contains("${name}")) {
-            throw new ConfigInvalidException(
-                String.format(
-                    "remote.%s.url \"%s\" lacks ${name} placeholder in %s",
-                    c.getName(), u, config));
-          }
-        }
-      }
-
-      confs.add(destinationConfiguration);
-    }
-
-    return confs.build();
-  }
-
-  private static List<RemoteConfig> allRemotes(Config cfg) throws ConfigInvalidException {
-    Set<String> names = cfg.getSubsections("remote");
-    List<RemoteConfig> result = Lists.newArrayListWithCapacity(names.size());
-    for (String name : names) {
-      try {
-        result.add(new RemoteConfig(cfg, name));
-      } catch (URISyntaxException e) {
-        throw new ConfigInvalidException(
-            String.format("remote %s has invalid URL in %s", name, cfg), e);
-      }
-    }
-    return result;
-  }
+  List<RemoteConfiguration> parseRemotes(Config config) throws ConfigInvalidException;
 }
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 38cc9e0..f485c95 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -72,6 +72,8 @@
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -97,7 +99,9 @@
 
   private final ReplicationStateListener stateLog;
   private final Object stateLock = new Object();
-  private final Map<URIish, PushOne> pending = new HashMap<>();
+  // writes are covered by the stateLock, but some reads are still
+  // allowed without the lock
+  private final ConcurrentMap<URIish, PushOne> pending = new ConcurrentHashMap<>();
   private final Map<URIish, PushOne> inFlight = new HashMap<>();
   private final PushOne.Factory opFactory;
   private final DeleteProjectTask.Factory deleteProjectFactory;
@@ -316,6 +320,10 @@
                       "Error reading project %s from cache", project);
                   return false;
                 }
+                if (projectState == null) {
+                  repLog.atFine().log("Project %s does not exist", project);
+                  throw new NoSuchProjectException(project);
+                }
                 if (!projectState.statePermitsRead()) {
                   repLog.atFine().log("Project %s does not permit read", project);
                   return false;
@@ -335,7 +343,7 @@
                       .check(RefPermission.READ);
                 } catch (AuthException e) {
                   repLog.atFine().log(
-                      "Ref %s on project %s is not visible to calling user",
+                      "Ref %s on project %s is not visible to calling user %s",
                       ref, project, userProvider.get().getUserName().orElse("unknown"));
                   return false;
                 }
@@ -657,33 +665,34 @@
 
   List<URIish> getURIs(Project.NameKey project, String urlMatch) {
     List<URIish> r = Lists.newArrayListWithCapacity(config.getRemoteConfig().getURIs().size());
-    for (URIish uri : config.getRemoteConfig().getURIs()) {
-      if (matches(uri, urlMatch)) {
-        String name = project.get();
-        if (needsUrlEncoding(uri)) {
-          name = encode(name);
-        }
-        String remoteNameStyle = config.getRemoteNameStyle();
-        if (remoteNameStyle.equals("dash")) {
-          name = name.replace("/", "-");
-        } else if (remoteNameStyle.equals("underscore")) {
-          name = name.replace("/", "_");
-        } else if (remoteNameStyle.equals("basenameOnly")) {
-          name = FilenameUtils.getBaseName(name);
-        } else if (!remoteNameStyle.equals("slash")) {
-          repLog.atFine().log(
-              "Unknown remoteNameStyle: %s, falling back to slash", remoteNameStyle);
-        }
-        String replacedPath = replaceName(uri.getPath(), name, config.isSingleProjectMatch());
-        if (replacedPath != null) {
-          uri = uri.setPath(replacedPath);
-          r.add(uri);
-        }
+    for (URIish configUri : config.getRemoteConfig().getURIs()) {
+      URIish uri = getURI(configUri, project);
+      if (matches(configUri, urlMatch) || matches(uri, urlMatch)) {
+        r.add(uri);
       }
     }
     return r;
   }
 
+  URIish getURI(URIish template, Project.NameKey project) {
+    String name = project.get();
+    if (needsUrlEncoding(template)) {
+      name = encode(name);
+    }
+    String remoteNameStyle = config.getRemoteNameStyle();
+    if (remoteNameStyle.equals("dash")) {
+      name = name.replace("/", "-");
+    } else if (remoteNameStyle.equals("underscore")) {
+      name = name.replace("/", "_");
+    } else if (remoteNameStyle.equals("basenameOnly")) {
+      name = FilenameUtils.getBaseName(name);
+    } else if (!remoteNameStyle.equals("slash")) {
+      repLog.atFine().log("Unknown remoteNameStyle: %s, falling back to slash", remoteNameStyle);
+    }
+    String replacedPath = replaceName(template.getPath(), name, isSingleProjectMatch());
+    return (replacedPath != null) ? template.setPath(replacedPath) : template;
+  }
+
   static boolean needsUrlEncoding(URIish uri) {
     return "http".equalsIgnoreCase(uri.getScheme())
         || "https".equalsIgnoreCase(uri.getScheme())
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfigParser.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfigParser.java
new file mode 100644
index 0000000..9b6d431
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationConfigParser.java
@@ -0,0 +1,110 @@
+// Copyright (C) 2020 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.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import java.net.URISyntaxException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.transport.RefSpec;
+import org.eclipse.jgit.transport.RemoteConfig;
+import org.eclipse.jgit.transport.URIish;
+
+/**
+ * Implementation of {@link ConfigParser} for parsing {@link Config} to a collection of {@link
+ * DestinationConfiguration} objects
+ */
+public class DestinationConfigParser implements ConfigParser {
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.replication.ConfigParser#parseRemotes(org.eclipse.jgit.lib.Config)
+   */
+  @Override
+  public List<RemoteConfiguration> parseRemotes(Config config) throws ConfigInvalidException {
+
+    if (config.getSections().isEmpty()) {
+      logger.atWarning().log("Replication config does not exist or it's empty; not replicating");
+      return Collections.emptyList();
+    }
+
+    boolean defaultForceUpdate = config.getBoolean("gerrit", "defaultForceUpdate", false);
+
+    ImmutableList.Builder<RemoteConfiguration> confs = ImmutableList.builder();
+    for (RemoteConfig c : allRemotes(config)) {
+      if (c.getURIs().isEmpty()) {
+        continue;
+      }
+
+      if (!c.getFetchRefSpecs().isEmpty()) {
+        repLog.atInfo().log("Ignore '%s' endpoint: not a 'push' target", c.getName());
+        continue;
+      }
+
+      // If destination for push is not set assume equal to source.
+      for (RefSpec ref : c.getPushRefSpecs()) {
+        if (ref.getDestination() == null) {
+          ref.setDestination(ref.getSource());
+        }
+      }
+
+      if (c.getPushRefSpecs().isEmpty()) {
+        c.addPushRefSpec(
+            new RefSpec()
+                .setSourceDestination("refs/*", "refs/*")
+                .setForceUpdate(defaultForceUpdate));
+      }
+
+      DestinationConfiguration destinationConfiguration = new DestinationConfiguration(c, config);
+
+      if (!destinationConfiguration.isSingleProjectMatch()) {
+        for (URIish u : c.getURIs()) {
+          if (u.getPath() == null || !u.getPath().contains("${name}")) {
+            throw new ConfigInvalidException(
+                String.format(
+                    "remote.%s.url \"%s\" lacks ${name} placeholder in %s",
+                    c.getName(), u, config));
+          }
+        }
+      }
+
+      confs.add(destinationConfiguration);
+    }
+
+    return confs.build();
+  }
+
+  private static List<RemoteConfig> allRemotes(Config cfg) throws ConfigInvalidException {
+    Set<String> names = cfg.getSubsections("remote");
+    List<RemoteConfig> result = Lists.newArrayListWithCapacity(names.size());
+    for (String name : names) {
+      try {
+        result.add(new RemoteConfig(cfg, name));
+      } catch (URISyntaxException e) {
+        throw new ConfigInvalidException(
+            String.format("remote %s has invalid URL in %s", name, cfg), e);
+      }
+    }
+    return result;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/GerritRestApi.java b/src/main/java/com/googlesource/gerrit/plugins/replication/GerritRestApi.java
index 079087c..fa81dd0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/GerritRestApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/GerritRestApi.java
@@ -16,8 +16,8 @@
 
 import static com.googlesource.gerrit.plugins.replication.GerritSshApi.GERRIT_ADMIN_PROTOCOL_PREFIX;
 import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.base.Charsets;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.inject.Inject;
@@ -69,7 +69,7 @@
           .execute(new HttpPut(url), new HttpResponseHandler(), getContext())
           .isSuccessful();
     } catch (IOException e) {
-      repLog.atSevere().log("Couldn't perform project creation on %s", uri, e);
+      repLog.atSevere().withCause(e).log("Couldn't perform project creation on %s", uri);
       return false;
     }
   }
@@ -82,7 +82,7 @@
       httpClient.execute(new HttpDelete(url), new HttpResponseHandler(), getContext());
       return true;
     } catch (IOException e) {
-      repLog.atSevere().log("Couldn't perform project deletion on %s", uri, e);
+      repLog.atSevere().withCause(e).log("Couldn't perform project deletion on %s", uri);
     }
     return false;
   }
@@ -93,13 +93,12 @@
     String url = String.format("%s/a/projects/%s/HEAD", toHttpUri(uri), Url.encode(project.get()));
     try {
       HttpPut req = new HttpPut(url);
-      req.setEntity(
-          new StringEntity(String.format("{\"ref\": \"%s\"}", newHead), Charsets.UTF_8.name()));
+      req.setEntity(new StringEntity(String.format("{\"ref\": \"%s\"}", newHead), UTF_8.name()));
       req.addHeader(new BasicHeader("Content-Type", "application/json"));
       httpClient.execute(req, new HttpResponseHandler(), getContext());
       return true;
     } catch (IOException e) {
-      repLog.atSevere().log("Couldn't perform update head on %s", uri, e);
+      repLog.atSevere().withCause(e).log("Couldn't perform update head on %s", uri);
     }
     return false;
   }
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 199acce..982e19e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.WorkQueue.CanceledWhileRunning;
 import com.google.gerrit.server.ioutil.HexFormat;
+import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -74,7 +75,6 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.Transport;
 import org.eclipse.jgit.transport.URIish;
-import org.slf4j.MDC;
 
 /**
  * A push to remote operation started by {@link GitReferenceUpdatedListener}.
@@ -85,7 +85,7 @@
 class PushOne implements ProjectRunnable, CanceledWhileRunning {
   private final ReplicationStateListener stateLog;
   static final String ALL_REFS = "..all..";
-  static final String ID_MDC_KEY = "pushOneId";
+  static final String ID_KEY = "pushOneId";
 
   interface Factory {
     PushOne create(Project.NameKey d, URIish u);
@@ -104,6 +104,7 @@
   private final Set<String> delta = Sets.newHashSetWithExpectedSize(4);
   private boolean pushAllRefs;
   private Repository git;
+  private boolean isCollision;
   private boolean retrying;
   private int retryCount;
   private final int maxRetries;
@@ -281,7 +282,7 @@
   }
 
   private void statesCleanUp() {
-    if (!stateMap.isEmpty() && !isRetrying()) {
+    if (!stateMap.isEmpty() && !isRetrying() && !isCollision) {
       for (Map.Entry<String, ReplicationState> entry : stateMap.entries()) {
         entry
             .getValue()
@@ -310,12 +311,18 @@
   }
 
   private void runPushOperation() {
+    try (TraceContext ctx = TraceContext.open().addTag(ID_KEY, HexFormat.fromInt(id))) {
+      doRunPushOperation();
+    }
+  }
+
+  private void doRunPushOperation() {
     // Lock the queue, and remove ourselves, so we can't be modified once
     // we start replication (instead a new instance, with the same URI, is
     // created and scheduled for a future point in time.)
     //
-    MDC.put(ID_MDC_KEY, HexFormat.fromInt(id));
     RunwayStatus status = pool.requestRunway(this);
+    isCollision = false;
     if (!status.isAllowed()) {
       if (status.isCanceled()) {
         repLog.atInfo().log(
@@ -327,6 +334,7 @@
             "Rescheduling replication to %s to avoid collision with the in-flight push [%s].",
             uri, HexFormat.fromInt(status.getInFlightPushId()));
         pool.reschedule(this, Destination.RetryReason.COLLISION);
+        isCollision = true;
       }
       return;
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
index ad68d42..92ba4be 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushResultProcessing.java
@@ -25,36 +25,54 @@
 import org.eclipse.jgit.transport.RemoteRefUpdate;
 import org.eclipse.jgit.transport.URIish;
 
-public abstract class PushResultProcessing {
+public interface PushResultProcessing {
+  public static final PushResultProcessing NO_OP = new PushResultProcessing() {};
 
-  abstract void onRefReplicatedToOneNode(
+  /**
+   * Invoked when a ref has been replicated to one node.
+   *
+   * @param project
+   * @param ref
+   * @param uri
+   * @param status
+   * @param refStatus
+   */
+  default void onRefReplicatedToOneNode(
       String project,
       String ref,
       URIish uri,
       RefPushResult status,
-      RemoteRefUpdate.Status refStatus);
+      RemoteRefUpdate.Status refStatus) {}
 
-  abstract void onRefReplicatedToAllNodes(String project, String ref, int nodesCount);
+  /**
+   * Invoked when a ref has been replicated to all nodes.
+   *
+   * @param project
+   * @param ref
+   * @param nodesCount
+   */
+  default void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {}
 
-  abstract void onAllRefsReplicatedToAllNodes(int totalPushTasksCount);
+  /**
+   * Invoked when all refs have been replicated to all nodes.
+   *
+   * @param totalPushTasksCount
+   */
+  default void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {}
 
   /**
    * Write message to standard out.
    *
    * @param message message text.
    */
-  void writeStdOut(String message) {
-    // Default doing nothing
-  }
+  default void writeStdOut(String message) {}
 
   /**
    * Write message to standard error.
    *
    * @param message message text.
    */
-  void writeStdErr(String message) {
-    // Default doing nothing
-  }
+  default void writeStdErr(String message) {}
 
   static String resolveNodeName(URIish uri) {
     StringBuilder sb = new StringBuilder();
@@ -70,7 +88,7 @@
     return sb.toString();
   }
 
-  public static class CommandProcessing extends PushResultProcessing {
+  public static class CommandProcessing implements PushResultProcessing {
     private WeakReference<StartCommand> sshCommand;
     private AtomicBoolean hasError = new AtomicBoolean();
 
@@ -79,7 +97,7 @@
     }
 
     @Override
-    void onRefReplicatedToOneNode(
+    public void onRefReplicatedToOneNode(
         String project,
         String ref,
         URIish uri,
@@ -109,13 +127,13 @@
           break;
       }
       sb.append(" (");
-      sb.append(refStatus.toString());
+      sb.append(refStatus == null ? "unknown" : refStatus.toString());
       sb.append(")");
       writeStdOut(sb.toString());
     }
 
     @Override
-    void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
+    public void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
       StringBuilder sb = new StringBuilder();
       sb.append("Replication of ");
       sb.append(project);
@@ -128,7 +146,7 @@
     }
 
     @Override
-    void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {
+    public void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {
       if (totalPushTasksCount == 0) {
         return;
       }
@@ -141,7 +159,7 @@
     }
 
     @Override
-    void writeStdOut(String message) {
+    public void writeStdOut(String message) {
       StartCommand command = sshCommand.get();
       if (command != null) {
         command.writeStdOutSync(message);
@@ -149,7 +167,7 @@
     }
 
     @Override
-    void writeStdErr(String message) {
+    public void writeStdErr(String message) {
       StartCommand command = sshCommand.get();
       if (command != null) {
         command.writeStdErrSync(message);
@@ -157,7 +175,7 @@
     }
   }
 
-  public static class GitUpdateProcessing extends PushResultProcessing {
+  public static class GitUpdateProcessing implements PushResultProcessing {
     private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
     private final EventDispatcher dispatcher;
@@ -167,7 +185,7 @@
     }
 
     @Override
-    void onRefReplicatedToOneNode(
+    public void onRefReplicatedToOneNode(
         String project,
         String ref,
         URIish uri,
@@ -177,13 +195,10 @@
     }
 
     @Override
-    void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
+    public void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {
       postEvent(new RefReplicationDoneEvent(project, ref, nodesCount));
     }
 
-    @Override
-    void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {}
-
     private void postEvent(RefEvent event) {
       try {
         dispatcher.postEvent(event);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java
index b8da2e4..f96c157 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/RemoteSsh.java
@@ -44,7 +44,7 @@
       sshHelper.executeRemoteSsh(uri, cmd, errStream);
       repLog.atInfo().log("Created remote repository: %s", uri);
     } catch (IOException e) {
-      repLog.atSevere().withCause(e).log(
+      repLog.atSevere().log(
           "Error creating remote repository at %s:\n"
               + "  Exception: %s\n"
               + "  Command: %s\n"
@@ -64,8 +64,8 @@
       sshHelper.executeRemoteSsh(uri, cmd, errStream);
       repLog.atInfo().log("Deleted remote repository: %s", uri);
     } catch (IOException e) {
-      repLog.atSevere().withCause(e).log(
-          "Error deleting remote repository at %s}:\n"
+      repLog.atSevere().log(
+          "Error deleting remote repository at %s:\n"
               + "  Exception: %s\n"
               + "  Command: %s\n"
               + "  Output: %s",
@@ -84,7 +84,7 @@
     try {
       sshHelper.executeRemoteSsh(uri, cmd, errStream);
     } catch (IOException e) {
-      repLog.atSevere().withCause(e).log(
+      repLog.atSevere().log(
           "Error updating HEAD of remote repository at %s to %s:\n"
               + "  Exception: %s\n"
               + "  Command: %s\n"
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java
index fed09f9..60a9523 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationLogFile.java
@@ -28,6 +28,6 @@
         systemLog,
         serverInfo,
         ReplicationQueue.REPLICATION_LOG_NAME,
-        new PatternLayout("[%d] [%X{" + PushOne.ID_MDC_KEY + "}] %m%n"));
+        new PatternLayout("[%d] %m%n"));
   }
 }
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 ed1e348..c2b96a1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -80,7 +80,7 @@
 
     bind(EventBus.class).in(Scopes.SINGLETON);
     bind(ReplicationDestinations.class).to(DestinationsCollection.class);
-    bind(ConfigParser.class).in(Scopes.SINGLETON);
+    bind(ConfigParser.class).to(DestinationConfigParser.class).in(Scopes.SINGLETON);
 
     if (getReplicationConfig().getBoolean("gerrit", "autoReload", false)) {
       bind(ReplicationConfig.class)
diff --git a/src/main/resources/Documentation/cmd-start.md b/src/main/resources/Documentation/cmd-start.md
index 6af73af..8291421 100644
--- a/src/main/resources/Documentation/cmd-start.md
+++ b/src/main/resources/Documentation/cmd-start.md
@@ -97,10 +97,10 @@
 :	Schedule replication for all projects.
 
 `--url <PATTERN>`
-:	Replicate only to replication destinations whose URL contains
-	the substring `PATTERN`.  This can be useful to replicate
-	only to a previously down node, which has been brought back
-	online.
+:	Replicate only to replication destinations whose configuration
+	URL contains the substring `PATTERN`, or whose expanded project
+	URL contains `PATTERN`. This can be useful to replicate only to
+	a previously down node, which has been brought back online.
 
 EXAMPLES
 --------
@@ -136,6 +136,12 @@
   $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ start --url slave1 ^(|.*/)vendor(|/.*)
 ```
 
+Replicate to only one specific destination URL:
+
+```
+  $ ssh -p @SSH_PORT@ @SSH_HOST@ @PLUGIN@ start --url https://example.com/tools/gerrit.git
+```
+
 SEE ALSO
 --------
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
index c48bdbd..2b6a8c4 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
@@ -72,7 +72,7 @@
     sitePaths = new SitePaths(sitePath);
     pluginDataPath = createTempPath("data");
     destinationFactoryMock = mock(Destination.Factory.class);
-    configParser = new ConfigParser();
+    configParser = new DestinationConfigParser();
   }
 
   @Before
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java
index 93e8886..e7339d9 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadRunnableTest.java
@@ -105,14 +105,14 @@
     }
   }
 
-  private static class TestValidConfigurationListener extends ConfigParser {
+  private static class TestValidConfigurationListener implements ConfigParser {
     @Override
     public List<RemoteConfiguration> parseRemotes(Config newConfig) {
       return Collections.emptyList();
     }
   }
 
-  private static class TestInvalidConfigurationListener extends ConfigParser {
+  private static class TestInvalidConfigurationListener implements ConfigParser {
     @Override
     public List<RemoteConfiguration> parseRemotes(Config configurationChangeEvent)
         throws ConfigInvalidException {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
index 9cf5489..19948db 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.googlesource.gerrit.plugins.replication.PushResultProcessing.NO_OP;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.flogger.FluentLogger;
@@ -47,8 +48,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.transport.RemoteRefUpdate;
-import org.eclipse.jgit.transport.URIish;
 import org.eclipse.jgit.util.FS;
 import org.junit.Test;
 
@@ -206,25 +205,7 @@
 
   @Test
   public void shouldCreateOneReplicationTaskWhenSchedulingRepoFullSync() throws Exception {
-    PushResultProcessing pushResultProcessing =
-        new PushResultProcessing() {
-
-          @Override
-          void onRefReplicatedToOneNode(
-              String project,
-              String ref,
-              URIish uri,
-              ReplicationState.RefPushResult status,
-              RemoteRefUpdate.Status refStatus) {}
-
-          @Override
-          void onRefReplicatedToAllNodes(String project, String ref, int nodesCount) {}
-
-          @Override
-          void onAllRefsReplicatedToAllNodes(int totalPushTasksCount) {}
-        };
-
-    createTestProject("replica");
+    createTestProject(project + "replica");
 
     setReplicationDestination("foo", "replica", ALL_PROJECTS);
     reloadConfig();
@@ -232,12 +213,55 @@
     plugin
         .getSysInjector()
         .getInstance(ReplicationQueue.class)
-        .scheduleFullSync(project, null, new ReplicationState(pushResultProcessing), true);
+        .scheduleFullSync(project, null, new ReplicationState(NO_OP), true);
 
     assertThat(listReplicationTasks(".*all.*")).hasSize(1);
   }
 
   @Test
+  public void shouldMatchTemplatedURL() throws Exception {
+    createTestProject(project + "replica");
+
+    setReplicationDestination("foo", "replica", ALL_PROJECTS);
+    reloadConfig();
+
+    String urlMatch = gitPath.resolve("${name}" + "replica" + ".git").toString();
+    String expectedURI = gitPath.resolve(project + "replica" + ".git").toString();
+
+    plugin
+        .getSysInjector()
+        .getInstance(ReplicationQueue.class)
+        .scheduleFullSync(project, urlMatch, new ReplicationState(NO_OP), true);
+
+    assertThat(listReplicationTasks(".*all.*")).hasSize(1);
+    for (ReplicationTasksStorage.ReplicateRefUpdate task : tasksStorage.list()) {
+      assertThat(task.uri).isEqualTo(expectedURI);
+    }
+  }
+
+  @Test
+  public void shouldMatchRealURL() throws Exception {
+    createTestProject(project + "replica");
+
+    setReplicationDestination("foo", "replica", ALL_PROJECTS);
+    reloadConfig();
+
+    String urlMatch = gitPath.resolve(project + "replica" + ".git").toString();
+    String expectedURI = urlMatch;
+
+    plugin
+        .getSysInjector()
+        .getInstance(ReplicationQueue.class)
+        .scheduleFullSync(project, urlMatch, new ReplicationState(NO_OP), true);
+
+    assertThat(listReplicationTasks(".*")).hasSize(1);
+    for (ReplicationTasksStorage.ReplicateRefUpdate task : tasksStorage.list()) {
+      assertThat(task.uri).isEqualTo(expectedURI);
+    }
+    assertThat(tasksStorage.list()).isNotEmpty();
+  }
+
+  @Test
   public void shouldReplicateHeadUpdate() throws Exception {
     setReplicationDestination("foo", "replica", ALL_PROJECTS);
     reloadConfig();