Merge branch 'stable-3.9'

* stable-3.9:
  Pull-replication plugin should warn about inconsistent timeouts

Change-Id: Ifc22812bc9edc16ea757ef0a50669b5a6c0bf260
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
index 2eb26e8..baeb330 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/ReplicationQueue.java
@@ -774,7 +774,7 @@
   private void fireBeforeStartupEvents() {
     Set<String> eventsReplayed = new HashSet<>();
     ReferenceBatchUpdatedEvent event;
-    while ((event = beforeStartupEventsQueue.poll()) != null) {
+    while ((event = beforeStartupEventsQueue.peek()) != null) {
       String eventKey =
           String.format(
               "%s:%s",
@@ -787,6 +787,7 @@
         fire(event);
         eventsReplayed.add(eventKey);
       }
+      beforeStartupEventsQueue.remove(event);
     }
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
index 6c24cbc..a69afa4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/ProjectInitializationAction.java
@@ -27,12 +27,15 @@
 import com.google.common.net.MediaType;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.project.ProjectIndexer;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -70,6 +73,7 @@
   private final ProjectIndexer projectIndexer;
   private final ApplyObjectCommand applyObjectCommand;
   private final ProjectCache projectCache;
+  private final DynamicSet<NewProjectCreatedListener> newProjectCreatedListeners;
 
   @Inject
   ProjectInitializationAction(
@@ -78,13 +82,15 @@
       PermissionBackend permissionBackend,
       ProjectIndexer projectIndexer,
       ApplyObjectCommand applyObjectCommand,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      DynamicSet<NewProjectCreatedListener> newProjectCreatedListeners) {
     this.gerritConfigOps = gerritConfigOps;
     this.userProvider = userProvider;
     this.permissionBackend = permissionBackend;
     this.projectIndexer = projectIndexer;
     this.applyObjectCommand = applyObjectCommand;
     this.projectCache = projectCache;
+    this.newProjectCreatedListeners = newProjectCreatedListeners;
   }
 
   @Override
@@ -179,6 +185,10 @@
       throw new BadRequestException("Configuration data cannot contain change meta refs", e);
     }
     projectCache.onCreateProject(Project.nameKey(projectName));
+    // In case pull-replication is used in conjunction with multi-site, by convention the remote
+    // label is populated with the instanceId. That's why we are passing input.getLabel()
+    // to the Event to notify
+    notifyListenersOfNewProjectCreation(projectName, input.getLabel());
     repLog.info(
         "Init project API from {} for {}:{} - {}",
         input.getLabel(),
@@ -246,4 +256,38 @@
       return false;
     }
   }
+
+  private void notifyListenersOfNewProjectCreation(String projectName, String instanceId) {
+    NewProjectCreatedListener.Event newProjectCreatedEvent =
+        new Event(RefNames.REFS_CONFIG, projectName, instanceId);
+    newProjectCreatedListeners.forEach(l -> l.onNewProjectCreated(newProjectCreatedEvent));
+  }
+
+  private static class Event extends AbstractNoNotifyEvent
+      implements NewProjectCreatedListener.Event {
+    private final String headName;
+    private final String projectName;
+    private final String instanceId;
+
+    public Event(String headName, String projectName, String maybeInstanceId) {
+      this.headName = headName;
+      this.projectName = projectName;
+      this.instanceId = maybeInstanceId;
+    }
+
+    @Override
+    public String getHeadName() {
+      return headName;
+    }
+
+    @Override
+    public String getProjectName() {
+      return projectName;
+    }
+
+    @Override
+    public String getInstanceId() {
+      return instanceId;
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java
index 800f2b2..9a3b4fa 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadAction.java
@@ -15,56 +15,31 @@
 package com.googlesource.gerrit.plugins.replication.pull.api;
 
 import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.projects.HeadInput;
-import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.events.EventDispatcher;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import com.googlesource.gerrit.plugins.replication.LocalFS;
-import com.googlesource.gerrit.plugins.replication.pull.Context;
-import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
-import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps;
-import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
-import java.net.HttpURLConnection;
-import java.util.Optional;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.transport.URIish;
 
 @Singleton
 public class UpdateHeadAction implements RestModifyView<ProjectResource, HeadInput> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  private final GerritConfigOps gerritConfigOps;
   private final FetchPreconditions preconditions;
-  private final DynamicItem<EventDispatcher> eventDispatcher;
-  private static final URIish EMPTY_URI = new URIish();
+  private final UpdateHeadCommand updateHeadCommand;
 
   @Inject
-  UpdateHeadAction(
-      GerritConfigOps gerritConfigOps,
-      FetchPreconditions preconditions,
-      DynamicItem<EventDispatcher> eventDispatcher) {
-    this.gerritConfigOps = gerritConfigOps;
+  UpdateHeadAction(FetchPreconditions preconditions, UpdateHeadCommand updateHeadCommand) {
     this.preconditions = preconditions;
-    this.eventDispatcher = eventDispatcher;
+    this.updateHeadCommand = updateHeadCommand;
   }
 
   @Override
   public Response<?> apply(ProjectResource projectResource, HeadInput input)
       throws AuthException, BadRequestException, ResourceConflictException, Exception {
-    Response<String> res = null;
-
     if (input == null || Strings.isNullOrEmpty(input.ref)) {
       throw new BadRequestException("ref required");
     }
@@ -74,50 +49,8 @@
       throw new AuthException("Update head not permitted");
     }
 
-    // TODO: the .git suffix should not be added here, but rather it should be
-    //  dealt with by the caller, honouring the naming style from the
-    //  replication.config (Issue 15221)
-    Optional<URIish> maybeRepo =
-        gerritConfigOps.getGitRepositoryURI(String.format("%s.git", projectResource.getName()));
+    updateHeadCommand.doUpdate(projectResource.getNameKey(), ref);
 
-    try {
-      if (maybeRepo.isPresent()) {
-        if (new LocalFS(maybeRepo.get()).updateHead(projectResource.getNameKey(), ref)) {
-          return res = Response.ok(ref);
-        }
-        throw new UnprocessableEntityException(
-            String.format(
-                "Could not update HEAD of repo %s to ref %s", projectResource.getName(), ref));
-      }
-    } finally {
-      fireEvent(
-          projectResource.getNameKey(),
-          res != null && res.statusCode() == HttpURLConnection.HTTP_OK);
-    }
-    throw new ResourceNotFoundException(
-        String.format("Could not compute URL for repo: %s", projectResource.getName()));
-  }
-
-  private void fireEvent(Project.NameKey projectName, boolean succeeded) {
-    try {
-      Context.setLocalEvent(true);
-      eventDispatcher
-          .get()
-          .postEvent(
-              new FetchRefReplicatedEvent(
-                  projectName.get(),
-                  RefNames.HEAD,
-                  EMPTY_URI, // TODO: the remote label is not passed as parameter, hence cannot be
-                  // propagated to the event
-                  succeeded
-                      ? ReplicationState.RefFetchResult.SUCCEEDED
-                      : ReplicationState.RefFetchResult.FAILED,
-                  RefUpdate.Result.FORCED));
-    } catch (PermissionBackendException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot post event for refs/meta/config on project %s", projectName);
-    } finally {
-      Context.unsetLocalEvent();
-    }
+    return Response.ok(ref);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadCommand.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadCommand.java
new file mode 100644
index 0000000..53bf5d9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/api/UpdateHeadCommand.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2023 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.pull.api;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.EventDispatcher;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.LocalFS;
+import com.googlesource.gerrit.plugins.replication.pull.Context;
+import com.googlesource.gerrit.plugins.replication.pull.FetchRefReplicatedEvent;
+import com.googlesource.gerrit.plugins.replication.pull.GerritConfigOps;
+import com.googlesource.gerrit.plugins.replication.pull.ReplicationState;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.URIish;
+
+@Singleton
+public class UpdateHeadCommand {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final URIish EMPTY_URI = new URIish();
+
+  private final GerritConfigOps gerritConfigOps;
+  private final DynamicItem<EventDispatcher> eventDispatcher;
+
+  @Inject
+  UpdateHeadCommand(GerritConfigOps gerritConfigOps, DynamicItem<EventDispatcher> eventDispatcher) {
+    this.gerritConfigOps = gerritConfigOps;
+    this.eventDispatcher = eventDispatcher;
+  }
+
+  public void doUpdate(Project.NameKey project, String ref)
+      throws UnprocessableEntityException, ResourceNotFoundException {
+    boolean succeeded = false;
+    // TODO: the .git suffix should not be added here, but rather it should be
+    //  dealt with by the caller, honouring the naming style from the
+    //  replication.config (Issue 15221)
+    Optional<URIish> maybeRepo =
+        gerritConfigOps.getGitRepositoryURI(String.format("%s.git", project.get()));
+
+    logger.atInfo().log("do update: %s %s", project, ref);
+    try {
+      if (maybeRepo.isPresent()) {
+        if (new LocalFS(maybeRepo.get()).updateHead(project, ref)) {
+          succeeded = true;
+          return;
+        }
+
+        throw new UnprocessableEntityException(
+            String.format("Could not update HEAD of repo %s to ref %s", project, ref));
+      }
+    } finally {
+      fireEvent(project, succeeded);
+    }
+    throw new ResourceNotFoundException(
+        String.format("Could not compute URL for repo: %s", project.get()));
+  }
+
+  private void fireEvent(Project.NameKey projectName, boolean succeeded) {
+    try {
+      Context.setLocalEvent(true);
+      eventDispatcher
+          .get()
+          .postEvent(
+              new FetchRefReplicatedEvent(
+                  projectName.get(),
+                  RefNames.HEAD,
+                  EMPTY_URI, // TODO: the remote label is not passed as parameter, hence cannot be
+                  // propagated to the event
+                  succeeded
+                      ? ReplicationState.RefFetchResult.SUCCEEDED
+                      : ReplicationState.RefFetchResult.FAILED,
+                  RefUpdate.Result.FORCED));
+    } catch (PermissionBackendException e) {
+      logger.atSevere().withCause(e).log(
+          "Cannot post event for refs/meta/config on project %s", projectName);
+    } finally {
+      Context.unsetLocalEvent();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/EventsBrokerMessageConsumer.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/EventsBrokerMessageConsumer.java
index a9b4945..cf7abba 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/EventsBrokerMessageConsumer.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/EventsBrokerMessageConsumer.java
@@ -23,6 +23,8 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
@@ -58,7 +60,11 @@
     try {
       eventListener.fetchRefsForEvent(event);
       if (shutdownState.isShuttingDown()) stop();
-    } catch (AuthException | PermissionBackendException | IOException e) {
+    } catch (AuthException
+        | PermissionBackendException
+        | IOException
+        | UnprocessableEntityException
+        | ResourceNotFoundException e) {
       throw new EventRejectedException(event, e);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java
index 4c621ac..2f992cd 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListener.java
@@ -24,13 +24,16 @@
 import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.GerritInstanceId;
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.EventListener;
 import com.google.gerrit.server.events.ProjectCreatedEvent;
 import com.google.gerrit.server.events.ProjectEvent;
+import com.google.gerrit.server.events.ProjectHeadUpdatedEvent;
 import com.google.gerrit.server.events.RefUpdatedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -47,6 +50,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob.Factory;
 import com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction;
 import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
+import com.googlesource.gerrit.plugins.replication.pull.api.UpdateHeadCommand;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.io.IOException;
 import java.util.Optional;
@@ -59,6 +63,7 @@
   private final DeleteRefCommand deleteCommand;
   private final ExcludedRefsFilter refsFilter;
   private final Factory fetchJobFactory;
+  private final UpdateHeadCommand updateHeadCommand;
   private final ProjectInitializationAction projectInitializationAction;
   private final Provider<PullReplicationApiRequestMetrics> metricsProvider;
   private final SourcesCollection sources;
@@ -70,6 +75,7 @@
   public StreamEventListener(
       @Nullable @GerritInstanceId String instanceId,
       DeleteRefCommand deleteCommand,
+      UpdateHeadCommand updateHeadCommand,
       ProjectInitializationAction projectInitializationAction,
       WorkQueue workQueue,
       FetchJob.Factory fetchJobFactory,
@@ -79,6 +85,7 @@
       @Named(APPLY_OBJECTS_CACHE) Cache<ApplyObjectsCacheKey, Long> refUpdatesSucceededCache) {
     this.instanceId = instanceId;
     this.deleteCommand = deleteCommand;
+    this.updateHeadCommand = updateHeadCommand;
     this.projectInitializationAction = projectInitializationAction;
     this.workQueue = workQueue;
     this.fetchJobFactory = fetchJobFactory;
@@ -95,7 +102,11 @@
   public void onEvent(Event event) {
     try {
       fetchRefsForEvent(event);
-    } catch (AuthException | PermissionBackendException | IOException e) {
+    } catch (AuthException
+        | PermissionBackendException
+        | IOException
+        | UnprocessableEntityException
+        | ResourceNotFoundException e) {
       logger.atSevere().withCause(e).log(
           "This is the event handler of Gerrit's event-bus. It isn't"
               + "supposed to throw any exception, otherwise the other handlers "
@@ -104,7 +115,8 @@
   }
 
   public void fetchRefsForEvent(Event event)
-      throws AuthException, PermissionBackendException, IOException {
+      throws AuthException, PermissionBackendException, IOException, UnprocessableEntityException,
+          ResourceNotFoundException {
     if (instanceId.equals(event.instanceId) || !shouldReplicateProject(event)) {
       return;
     }
@@ -159,6 +171,15 @@
             "Cannot initialise project:%s", projectCreatedEvent.projectName);
         throw e;
       }
+    } else if (event instanceof ProjectHeadUpdatedEvent) {
+      ProjectHeadUpdatedEvent headUpdatedEvent = (ProjectHeadUpdatedEvent) event;
+      try {
+        updateHeadCommand.doUpdate(headUpdatedEvent.getProjectNameKey(), headUpdatedEvent.newHead);
+      } catch (UnprocessableEntityException | ResourceNotFoundException e) {
+        logger.atSevere().withCause(e).log(
+            "Failed to update HEAD on project: %s", headUpdatedEvent.projectName);
+        throw e;
+      }
     }
   }
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java
index 552853e..e91ce8d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/pull/event/StreamEventListenerTest.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.ProjectCreatedEvent;
+import com.google.gerrit.server.events.ProjectHeadUpdatedEvent;
 import com.google.gerrit.server.events.RefUpdatedEvent;
 import com.google.gerrit.server.git.WorkQueue;
 import com.googlesource.gerrit.plugins.replication.pull.ApplyObjectsCacheKey;
@@ -40,6 +41,7 @@
 import com.googlesource.gerrit.plugins.replication.pull.api.FetchJob;
 import com.googlesource.gerrit.plugins.replication.pull.api.ProjectInitializationAction;
 import com.googlesource.gerrit.plugins.replication.pull.api.PullReplicationApiRequestMetrics;
+import com.googlesource.gerrit.plugins.replication.pull.api.UpdateHeadCommand;
 import com.googlesource.gerrit.plugins.replication.pull.filter.ExcludedRefsFilter;
 import java.util.concurrent.ScheduledExecutorService;
 import org.eclipse.jgit.lib.ObjectId;
@@ -66,6 +68,7 @@
   @Mock private ScheduledExecutorService executor;
   @Mock private FetchJob fetchJob;
   @Mock private FetchJob.Factory fetchJobFactory;
+  @Mock private UpdateHeadCommand updateHeadCommand;
   @Mock private DeleteRefCommand deleteRefCommand;
   @Captor ArgumentCaptor<Input> inputCaptor;
   @Mock private PullReplicationApiRequestMetrics metrics;
@@ -92,6 +95,7 @@
         new StreamEventListener(
             INSTANCE_ID,
             deleteRefCommand,
+            updateHeadCommand,
             projectInitializationAction,
             workQueue,
             fetchJobFactory,
@@ -311,4 +315,17 @@
 
     verify(executor).submit(any(FetchJob.class));
   }
+
+  @Test
+  public void shouldUpdateProjectHeadOnProjectHeadUpdatedEvent() throws Exception {
+    ProjectHeadUpdatedEvent event = new ProjectHeadUpdatedEvent();
+    event.projectName = TEST_PROJECT;
+    event.oldHead = "refs/heads/master";
+    event.newHead = "refs/heads/main";
+    event.instanceId = REMOTE_INSTANCE_ID;
+
+    objectUnderTest.onEvent(event);
+
+    verify(updateHeadCommand).doUpdate(event.getProjectNameKey(), event.newHead);
+  }
 }