Forward project-wide change index deletions

When a project is deleted, all of its changes are removed from the
index.

Listen to the core onAllChangesDeletedForProject hook and forward a
single project-scoped change-index event so peers can delete all change
documents for that project in one operation.

Key changes:
- Add handling of onAllChangesDeletedForProject in IndexEventHandler and
  enqueue a task that emits a consolidated change-index event.
- Extend ChangeIndexEvent with an "all deleted for project" marker
  (deleted=true, changeId=0), plus helpers to create/detect it.
- Teach IndexEventRouter to route the special event to a new
  ForwardedIndexChangeHandler.deleteAllForProject path.
- Implement deleteAllForProject in the handler, invoking
  ChangeIndexer.deleteAllForProject(Project.NameKey) under a forwarded
  context to avoid echo loops.

Bug: Issue 440670678
Change-Id: I933a06bf9ce75eb6998f828425234f4677e5bcd7
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandler.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandler.java
index 15f7c13..8a52eb5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandler.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandler.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -103,6 +104,28 @@
     }
   }
 
+  /**
+   * Deletes all change index entries associated with the given project.
+   *
+   * <p>This method is invoked when a project-wide deletion event is received from a peer in a
+   * multi-site setup. It removes all changes for the specified project from the local index.
+   *
+   * <p>The method temporarily marks the execution context as a forwarded event to prevent the
+   * resulting index updates from being re-forwarded to other peers, which would otherwise create
+   * event loops.
+   *
+   * @param projectName the name of the project whose changes should be removed from the index
+   */
+  public void deleteAllForProject(String projectName) {
+    try {
+      Context.setForwardedEvent(true);
+      log.debug("Deleting all change indexes for project {}", projectName);
+      indexer.deleteAllForProject(Project.nameKey(projectName));
+    } finally {
+      Context.unsetForwardedEvent();
+    }
+  }
+
   @Override
   protected void reindex(String id) {
     try (ManualRequestContext ctx = oneOffCtx.open()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/events/ChangeIndexEvent.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/events/ChangeIndexEvent.java
index 64fbdfb..af4c0bc 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/events/ChangeIndexEvent.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/events/ChangeIndexEvent.java
@@ -22,12 +22,37 @@
 
 public class ChangeIndexEvent extends IndexEvent {
   static final String TYPE = "change-index";
+  private static final int ALL_CHANGES_FOR_PROJECT = 0;
 
   public String projectName;
   public int changeId;
   public String targetSha;
   public boolean deleted;
 
+  /**
+   * Creates a {@link ChangeIndexEvent} that represents the deletion of all changes belonging to the
+   * given project.
+   *
+   * @param projectName the name of the project
+   * @param instanceId the originating instance identifier
+   * @return an event marking all project changes as deleted
+   */
+  public static ChangeIndexEvent allChangesDeletedForProject(
+      String projectName, String instanceId) {
+    return new ChangeIndexEvent(
+        projectName, ALL_CHANGES_FOR_PROJECT, /* deleted */ true, instanceId);
+  }
+
+  /**
+   * Checks whether the given event represents the deletion of all changes for a project.
+   *
+   * @param event the event to check
+   * @return {@code true} if the event signals a project-wide deletion, {@code false} otherwise
+   */
+  public static boolean isAllChangesDeletedForProject(ChangeIndexEvent event) {
+    return event.deleted && event.changeId == ALL_CHANGES_FOR_PROJECT;
+  }
+
   public ChangeIndexEvent(String projectName, int changeId, boolean deleted, String instanceId) {
     super(TYPE, instanceId);
     this.projectName = projectName;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/router/IndexEventRouter.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/router/IndexEventRouter.java
index a647bce..a23ca33 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/router/IndexEventRouter.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/forwarder/router/IndexEventRouter.java
@@ -71,11 +71,16 @@
   public void route(IndexEvent sourceEvent) throws IOException {
     if (sourceEvent instanceof ChangeIndexEvent) {
       ChangeIndexEvent changeIndexEvent = (ChangeIndexEvent) sourceEvent;
-      ForwardedIndexingHandler.Operation operation = changeIndexEvent.deleted ? DELETE : INDEX;
-      indexChangeHandler.index(
-          changeIndexEvent.projectName + "~" + changeIndexEvent.changeId,
-          operation,
-          Optional.of(changeIndexEvent));
+
+      if (ChangeIndexEvent.isAllChangesDeletedForProject(changeIndexEvent)) {
+        indexChangeHandler.deleteAllForProject(changeIndexEvent.projectName);
+      } else {
+        ForwardedIndexingHandler.Operation operation = changeIndexEvent.deleted ? DELETE : INDEX;
+        indexChangeHandler.index(
+            changeIndexEvent.projectName + "~" + changeIndexEvent.changeId,
+            operation,
+            Optional.of(changeIndexEvent));
+      }
     } else if (sourceEvent instanceof AccountIndexEvent) {
       AccountIndexEvent accountIndexEvent = (AccountIndexEvent) sourceEvent;
       indexAccountHandler.indexAsync(Account.id(accountIndexEvent.accountId), INDEX);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/multisite/index/IndexEventHandler.java b/src/main/java/com/googlesource/gerrit/plugins/multisite/index/IndexEventHandler.java
index 9aee56e..83f610d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/multisite/index/IndexEventHandler.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/multisite/index/IndexEventHandler.java
@@ -85,6 +85,11 @@
   }
 
   @Override
+  public void onAllChangesDeletedForProject(String projectName) {
+    currCtx.onlyWithContext((ctx) -> executeAllChangesDeletedForProject(projectName));
+  }
+
+  @Override
   public void onChangeDeleted(int id) {
     executeDeleteChangeTask(id);
   }
@@ -111,6 +116,17 @@
     }
   }
 
+  private void executeAllChangesDeletedForProject(String projectName) {
+    if (!Context.isForwardedEvent()) {
+      IndexChangeTask task =
+          new IndexChangeTask(
+              ChangeIndexEvent.allChangesDeletedForProject(projectName, instanceId));
+      if (queuedTasks.add(task)) {
+        executor.execute(task);
+      }
+    }
+  }
+
   private void executeIndexChangeTask(String projectName, int id) {
     if (!Context.isForwardedEvent()) {
       ChangeChecker checker = changeChecker.create(projectName + "~" + id);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/multisite/event/IndexEventRouterTest.java b/src/test/java/com/googlesource/gerrit/plugins/multisite/event/IndexEventRouterTest.java
index 4fa1735..8239cf2 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/multisite/event/IndexEventRouterTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/multisite/event/IndexEventRouterTest.java
@@ -154,4 +154,16 @@
     verifyNoInteractions(
         indexAccountHandler, indexChangeHandler, indexGroupHandler, indexProjectHandler);
   }
+
+  @Test
+  public void routerShouldSendEventsToTheAppropriateHandler_allChangesDeletedForProject()
+      throws Exception {
+    ChangeIndexEvent event =
+        ChangeIndexEvent.allChangesDeletedForProject("projectName", INSTANCE_ID);
+    router.route(event);
+
+    verify(indexChangeHandler).deleteAllForProject(event.projectName);
+
+    verifyNoInteractions(indexAccountHandler, indexGroupHandler, indexProjectHandler);
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandlerTest.java b/src/test/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandlerTest.java
index 7133a0d..984e39f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandlerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/multisite/forwarder/ForwardedIndexChangeHandlerTest.java
@@ -203,6 +203,13 @@
     verify(indexerMock, times(1)).index(any(ChangeNotes.class));
   }
 
+  @Test
+  public void shouldDeleteAllForProject() {
+    handler.deleteAllForProject(TEST_PROJECT);
+
+    verify(indexerMock, times(1)).deleteAllForProject(Project.nameKey(TEST_PROJECT));
+  }
+
   private void setupChangeAccessRelatedMocks(boolean changeExist, boolean changeUpToDate)
       throws Exception {
     setupChangeAccessRelatedMocks(