Allow setting remote.NAME.threads=0 for persist-only mode

Setting 'remote.NAME.threads' to 0 now puts a remote into a
persist-only mode on the current node. No thread pool is created and
the node performs no pushes, project deletions or HEAD updates for
that remote. Replication tasks are still written to the task storage.

This supports multi-primary deployments where a user-facing primary
only records replication work, while a separate non user-facing
primary (configured with threads > 0 and distributor enabled) picks
the tasks up from the shared storage and performs the actual pushes.
Also, when a node has no push-enabled remotes, both recovery and the
startup replay are skipped entirely.

A caveat to setting threads to '0' is that project deletions and HEAD
updates originating on a persist-only node are not replicated by other
nodes, as they are not persisted to the task storage. If needed, support
for persisting them can be added in future changes.

Change-Id: I022865cbd7105563b5760ca05ae85847e0d7b97b
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 f83e1df..b35a5d1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -287,9 +287,21 @@
     return queue;
   }
 
+  /**
+   * Whether this destination performs pushes.
+   *
+   * <p>When {@code remote.NAME.threads} is set to 0, no pushes will be done, but the tasks are
+   * still persisted to the {@link ReplicationTasksStorage}.
+   */
+  public boolean isPushEnabled() {
+    return config.getPoolThreads() > 0;
+  }
+
   public void start(WorkQueue workQueue) {
-    String poolName = "ReplicateTo-" + config.getRemoteConfig().getName();
-    pool = workQueue.createQueue(config.getPoolThreads(), poolName);
+    if (isPushEnabled()) {
+      String poolName = "ReplicateTo-" + config.getRemoteConfig().getName();
+      pool = workQueue.createQueue(config.getPoolThreads(), poolName);
+    }
   }
 
   public int shutdown() {
@@ -455,6 +467,9 @@
       ReplicationState state,
       boolean now,
       boolean fromStorage) {
+    if (!isPushEnabled()) {
+      return;
+    }
     ImmutableSet.Builder<String> toSchedule = ImmutableSet.builder();
     for (String ref : refs) {
       if (!shouldReplicate(project, ref, state)) {
@@ -544,17 +559,21 @@
   }
 
   void scheduleDeleteProject(URIish uri, Project.NameKey project, ProjectDeletionState state) {
-    repLog.atFine().log("scheduling deletion of project %s at %s", project, uri);
-    @SuppressWarnings("unused")
-    ScheduledFuture<?> ignored =
-        pool.schedule(deleteProjectFactory.create(uri, project, state), 0, TimeUnit.SECONDS);
-    state.setScheduled(uri);
+    if (isPushEnabled()) {
+      repLog.atFine().log("scheduling deletion of project %s at %s", project, uri);
+      @SuppressWarnings("unused")
+      ScheduledFuture<?> ignored =
+          pool.schedule(deleteProjectFactory.create(uri, project, state), 0, TimeUnit.SECONDS);
+      state.setScheduled(uri);
+    }
   }
 
   void scheduleUpdateHead(URIish uri, Project.NameKey project, String newHead) {
-    @SuppressWarnings("unused")
-    ScheduledFuture<?> ignored =
-        pool.schedule(updateHeadFactory.create(uri, project, newHead), 0, TimeUnit.SECONDS);
+    if (isPushEnabled()) {
+      @SuppressWarnings("unused")
+      ScheduledFuture<?> ignored =
+          pool.schedule(updateHeadFactory.create(uri, project, newHead), 0, TimeUnit.SECONDS);
+    }
   }
 
   /**
@@ -579,6 +598,9 @@
    * @param pushOp The PushOp instance to be scheduled.
    */
   void reschedule(PushOne pushOp, RetryReason reason) {
+    if (!isPushEnabled()) {
+      return;
+    }
     RescheduleStatus status = new RescheduleStatus();
     stateLock.withWriteLock(
         pushOp.getURI(),
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 59acb20..9abf407 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationQueue.java
@@ -102,8 +102,11 @@
     if (!running) {
       destinations.get().startup(workQueue);
       running = true;
-      replicationTasksStorage.recoverAll();
-      synchronizePendingEvents(Prune.FALSE);
+      Set<String> pushEnabledRemoteNames = getPushEnabledRemoteNames();
+      if (!pushEnabledRemoteNames.isEmpty()) {
+        replicationTasksStorage.recoverAll(r -> pushEnabledRemoteNames.contains(r.remote()));
+        synchronizePendingEvents(Prune.FALSE);
+      }
       fireBeforeStartupEvents();
       distributor = new Distributor(workQueue);
     }
@@ -150,6 +153,13 @@
     fire(event.getProjectName(), event.getUpdatedRefs());
   }
 
+  private Set<String> getPushEnabledRemoteNames() {
+    return destinations.get().getAll(FilterType.ALL).stream()
+        .filter(Destination::isPushEnabled)
+        .map(Destination::getRemoteConfigName)
+        .collect(Collectors.toSet());
+  }
+
   private void fire(String projectName, Set<UpdatedRef> updatedRefs) {
     ReplicationState state = new ReplicationState(new GitUpdateProcessing(dispatcher));
     fire(Project.nameKey(projectName), null, updatedRefs, state, false);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java
index 7fc4acb..bc0d900 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorage.java
@@ -44,6 +44,7 @@
 import java.util.HashSet;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.transport.URIish;
@@ -189,8 +190,13 @@
     }
   }
 
+  @VisibleForTesting
   public void recoverAll() {
-    streamRunning().forEach(r -> new Task(r).recover());
+    recoverAll(r -> true);
+  }
+
+  public void recoverAll(Predicate<ReplicateRefUpdate> shouldRecover) {
+    streamRunning().filter(shouldRecover).forEach(r -> new Task(r).recover());
   }
 
   public boolean isWaiting(UriUpdates uriUpdates) {
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 305bb28..eb96d29 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -536,6 +536,17 @@
 	remote block describes 4 URLs, allocating 4 threads in the
 	pool will permit some level of parallel pushing.
 
+	A value of `0` puts this remote into a persist-only mode. No thread
+	pool is created and this node performs no pushes. Replication tasks
+	are still written to the replication task storage, so that other
+	nodes in the cluster sharing the same storage (with `threads` > 0 and
+	`replication.distributionInterval` enabled) pick them up and perform
+	the actual pushes.
+
+	> **NOTE**: In persist-only mode, project deletions and HEAD updates
+	> originating on this node cannot be replicated by other nodes, as
+	> they are not persisted to the task storage.
+
 	By default, 1 thread.
 
 remote.NAME.authGroup
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
index d0affe2..bfcf5be 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationStorageIT.java
@@ -67,6 +67,30 @@
   }
 
   @Test
+  public void shouldPersistTaskButNotPushWhenRemoteHasZeroThreads() throws Exception {
+    String remote = "persistOnly";
+    Project.NameKey target = createTestProject(project + "replica");
+    setReplicationDestination(remote, "replica", ALL_PROJECTS);
+    config.setInt("remote", remote, "threads", 0);
+    config.save();
+    reloadConfig();
+
+    String changeRef = createChange().getPatchSet().refName();
+
+    assertThat(waitingChangeReplicationTasksForRemote(changeRef, remote).count()).isEqualTo(1);
+
+    Destination destination =
+        destinationCollection.getAll(FilterType.ALL).stream()
+            .filter(dest -> remote.equals(dest.getRemoteConfigName()))
+            .findFirst()
+            .get();
+
+    assertThat(destination.isPushEnabled()).isFalse();
+    assertThat(destination.getQueue().pending).isEmpty();
+    assertThat(isPushCompleted(target, changeRef, TEST_PUSH_TIMEOUT)).isFalse();
+  }
+
+  @Test
   public void shouldCreateOneReplicationTaskWhenSchedulingRepoFullSync() throws Exception {
     createTestProject(project + "replica");
 
@@ -76,7 +100,8 @@
     plugin
         .getSysInjector()
         .getInstance(ReplicationQueue.class)
-        .scheduleFullSync(project, null, PushOne.ALL_REFS, Set.of(), new ReplicationState(NO_OP), false);
+        .scheduleFullSync(
+            project, null, PushOne.ALL_REFS, Set.of(), new ReplicationState(NO_OP), false);
 
     assertThat(listWaitingReplicationTasks(Pattern.quote(PushOne.ALL_REFS))).hasSize(1);
   }
@@ -221,7 +246,8 @@
     plugin
         .getSysInjector()
         .getInstance(ReplicationQueue.class)
-        .scheduleFullSync(project, urlMatch, PushOne.ALL_REFS, Set.of(), new ReplicationState(NO_OP), false);
+        .scheduleFullSync(
+            project, urlMatch, PushOne.ALL_REFS, Set.of(), new ReplicationState(NO_OP), false);
 
     assertThat(listWaiting()).hasSize(1);
     tasksStorage
@@ -246,7 +272,8 @@
     plugin
         .getSysInjector()
         .getInstance(ReplicationQueue.class)
-        .scheduleFullSync(project, urlMatch, PushOne.ALL_REFS, Set.of(), new ReplicationState(NO_OP), false);
+        .scheduleFullSync(
+            project, urlMatch, PushOne.ALL_REFS, Set.of(), new ReplicationState(NO_OP), false);
 
     assertThat(listWaiting()).hasSize(1);
     tasksStorage
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorageTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorageTest.java
index 6d334fa..cb1d541 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorageTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationTasksStorageTest.java
@@ -358,6 +358,23 @@
   }
 
   @Test
+  public void recoverAllRecoversOnlyUpdatesMatchingFilter() throws Exception {
+    ReplicateRefUpdate otherRefUpdate =
+        ReplicateRefUpdate.create(PROJECT, Set.of(REF), URISH, "otherRemote");
+    UriUpdates otherUriUpdates = new TestUriUpdates(otherRefUpdate);
+    storage.create(REF_UPDATE);
+    storage.create(otherRefUpdate);
+    storage.start(uriUpdates);
+    storage.start(otherUriUpdates);
+
+    storage.recoverAll(r -> REMOTE.equals(r.remote()));
+
+    assertThatStream(storage.streamWaiting()).containsExactly(STORED_REF_UPDATE);
+    assertThatStream(storage.streamRunning())
+        .containsExactly(ReplicateRefUpdate.create(otherRefUpdate, otherRefUpdate.sha1()));
+  }
+
+  @Test
   public void canCompleteMultipleRecoveredUpdates() throws Exception {
     ReplicateRefUpdate updateB =
         ReplicateRefUpdate.create(