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 (introduced by
I4c8a536290) and forward a single project-scoped index/change request so
peers can delete all change documents for that project in one operation.

To propagate this event to the peer nodes, this change adds:
- a REST endpoint: DELETE
  /plugins/high-availability/index/change/<project>~0
- A JGroups command

Bug: Issue 443765380
Change-Id: Idcd255f0e9778b6c4b8495dafd2f5ecdce4e1a91
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
index a787d3a..fd654ba 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandler.java
@@ -14,6 +14,8 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
+import static com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarder.ALL_CHANGES_FOR_PROJECT;
+
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeChecker;
 import com.ericsson.gerrit.plugins.highavailability.index.ChangeCheckerImpl;
 import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexExecutor;
@@ -119,19 +121,29 @@
   @Override
   protected CompletableFuture<Boolean> doDelete(String id, Optional<IndexEvent> indexEvent)
       throws IOException {
-    indexer.delete(parseChangeId(id));
-    log.atFine().log("Change %s successfully deleted from index", id);
+    if (ALL_CHANGES_FOR_PROJECT.equals(extractChangeId(id))) {
+      Project.NameKey projectName = parseProject(id);
+      indexer.deleteAllForProject(projectName);
+      log.atFine().log("All %s changes successfully deleted from index", projectName.get());
+    } else {
+      indexer.delete(parseChangeId(id));
+      log.atFine().log("Change %s successfully deleted from index", id);
+    }
     return CompletableFuture.completedFuture(true);
   }
 
   private static Change.Id parseChangeId(String id) {
-    return Change.id(Integer.parseInt(getChangeIdParts(id).get(1)));
+    return Change.id(Integer.parseInt(extractChangeId(id)));
   }
 
   private static Project.NameKey parseProject(String id) {
     return Project.nameKey(getChangeIdParts(id).get(0));
   }
 
+  private static String extractChangeId(String id) {
+    return getChangeIdParts(id).get(1);
+  }
+
   private static List<String> getChangeIdParts(String id) {
     return Splitter.on("~").splitToList(id);
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java
index b73b676..59ba29b 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/Forwarder.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.events.Event;
 import java.util.concurrent.CompletableFuture;
 
@@ -119,4 +120,13 @@
    *     false.
    */
   CompletableFuture<Boolean> removeFromProjectList(String projectName);
+
+  /**
+   * Forward the removal of all project changes from index to the other master.
+   *
+   * @param projectName the name of the project whose changes should be removed from the index
+   * @return {@link CompletableFuture} of true if successful, otherwise {@link CompletableFuture} of
+   *     false.
+   */
+  CompletableFuture<Boolean> deleteAllChangesForProject(Project.NameKey projectName);
 }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/DeleteAllProjectChangesFromIndex.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/DeleteAllProjectChangesFromIndex.java
new file mode 100644
index 0000000..79933fb
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/DeleteAllProjectChangesFromIndex.java
@@ -0,0 +1,32 @@
+// Copyright (C) 2025 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.ericsson.gerrit.plugins.highavailability.forwarder.jgroups;
+
+import com.google.gerrit.entities.Project;
+
+public class DeleteAllProjectChangesFromIndex extends Command {
+  static final String TYPE = "delete-all-project-changes-from-index";
+
+  private final Project.NameKey projectName;
+
+  protected DeleteAllProjectChangesFromIndex(Project.NameKey projectName) {
+    super(TYPE);
+    this.projectName = projectName;
+  }
+
+  public String getProjectName() {
+    return projectName.get();
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
index 40a358e..9f1f179 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/jgroups/JGroupsForwarder.java
@@ -19,6 +19,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.events.Event;
 import com.google.gson.Gson;
 import com.google.inject.Inject;
@@ -108,6 +109,11 @@
     return execute(new RemoveFromProjectList(projectName));
   }
 
+  @Override
+  public CompletableFuture<Boolean> deleteAllChangesForProject(Project.NameKey projectName) {
+    return execute(new DeleteAllProjectChangesFromIndex(projectName));
+  }
+
   private CompletableFuture<Boolean> execute(Command cmd) {
     return executor.getAsync(() -> executeOnce(cmd));
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
index 922fbd6..d53cd82 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarder.java
@@ -20,8 +20,10 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.rest.HttpResponseHandler.HttpResult;
 import com.ericsson.gerrit.plugins.highavailability.peers.PeerInfo;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.server.events.Event;
@@ -37,12 +39,14 @@
 import org.apache.http.HttpException;
 import org.apache.http.client.ClientProtocolException;
 
-class RestForwarder implements Forwarder {
+public class RestForwarder implements Forwarder {
   enum RequestMethod {
     POST,
     DELETE
   }
 
+  public static final String ALL_CHANGES_FOR_PROJECT = "0";
+
   private static final FluentLogger log = FluentLogger.forEnclosingClass();
 
   private final HttpSession httpSession;
@@ -115,6 +119,12 @@
     return escapedProjectName + '~' + changeId;
   }
 
+  @VisibleForTesting
+  public static String buildAllChangesForProjectEndpoint(String projectName) {
+    String escapedProjectName = Url.encode(projectName);
+    return escapedProjectName + '~' + ALL_CHANGES_FOR_PROJECT;
+  }
+
   @Override
   public CompletableFuture<Boolean> indexProject(String projectName, IndexEvent event) {
     return execute(
@@ -150,6 +160,15 @@
         Url.encode(projectName));
   }
 
+  @Override
+  public CompletableFuture<Boolean> deleteAllChangesForProject(Project.NameKey projectName) {
+    return execute(
+        RequestMethod.DELETE,
+        "Delete all project changes from index",
+        "index/change",
+        buildAllChangesForProjectEndpoint(projectName.get()));
+  }
+
   private static String buildProjectListEndpoint() {
     return Joiner.on("/").join("cache", Constants.PROJECT_LIST);
   }
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
index ec2a091..6475d99 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/index/IndexEventHandler.java
@@ -18,6 +18,7 @@
 import com.ericsson.gerrit.plugins.highavailability.forwarder.Forwarder;
 import com.ericsson.gerrit.plugins.highavailability.forwarder.IndexEvent;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
@@ -58,6 +59,17 @@
     currCtx.onlyWithContext((ctx) -> executeIndexChangeTask(projectName, id));
   }
 
+  @Override
+  public void onAllChangesDeletedForProject(String projectName) {
+    currCtx.onlyWithContext((ctx) -> executeAllChangesDeletedForProject(projectName));
+  }
+
+  private void executeAllChangesDeletedForProject(String projectName) {
+    if (!Context.isForwardedEvent()) {
+      forwarder.deleteAllChangesForProject(Project.nameKey(projectName));
+    }
+  }
+
   private void executeIndexChangeTask(String projectName, int id) {
     if (!Context.isForwardedEvent()) {
       String changeId = projectName + "~" + id;
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
index cc6939c..6a3a348 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexChangeHandlerTest.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder;
 
+import static com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarder.buildAllChangesForProjectEndpoint;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.concurrent.TimeUnit.SECONDS;
@@ -54,6 +55,7 @@
 
   private static final int TEST_CHANGE_NUMBER = 123;
   private static String TEST_PROJECT = "test/project";
+  private static final String TEST_PROJECT_ENCODED = "test%2Fproject";
   private static String TEST_CHANGE_ID = TEST_PROJECT + "~" + TEST_CHANGE_NUMBER;
   private static final boolean CHANGE_EXISTS = true;
   private static final boolean CHANGE_DOES_NOT_EXIST = false;
@@ -112,6 +114,14 @@
   }
 
   @Test
+  public void AllChangesAreDeletedFromIndex() throws Exception {
+    handler
+        .index(buildAllChangesForProjectEndpoint(TEST_PROJECT), Operation.DELETE, Optional.empty())
+        .get(10, SECONDS);
+    verify(indexerMock, times(1)).deleteAllForProject(Project.nameKey(TEST_PROJECT_ENCODED));
+  }
+
+  @Test
   public void changeToIndexDoesNotExist() throws Exception {
     setupChangeAccessRelatedMocks(CHANGE_DOES_NOT_EXIST, CHANGE_OUTDATED);
     handler.index(TEST_CHANGE_ID, Operation.INDEX, Optional.empty()).get(10, SECONDS);
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
index 8a2745e..4b98fe1 100644
--- a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/rest/RestForwarderTest.java
@@ -14,6 +14,7 @@
 
 package com.ericsson.gerrit.plugins.highavailability.forwarder.rest;
 
+import static com.ericsson.gerrit.plugins.highavailability.forwarder.rest.RestForwarder.buildAllChangesForProjectEndpoint;
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
@@ -32,6 +33,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gson.Gson;
@@ -80,6 +82,14 @@
               PROJECT_NAME_URL_END + "~" + CHANGE_NUMBER);
   private static final String DELETE_CHANGE_ENDPOINT =
       Joiner.on("/").join(URL, PLUGINS, PLUGIN_NAME, "index/change", "~" + CHANGE_NUMBER);
+  private static final String DELETE_ALL_CHANGES_ENDPOINT =
+      Joiner.on("/")
+          .join(
+              URL,
+              PLUGINS,
+              PLUGIN_NAME,
+              "index/change",
+              buildAllChangesForProjectEndpoint(PROJECT_NAME));
   private static final int ACCOUNT_NUMBER = 2;
   private static final String INDEX_ACCOUNT_ENDPOINT =
       Joiner.on("/").join(URL, PLUGINS, PLUGIN_NAME, "index/account", ACCOUNT_NUMBER);
@@ -246,6 +256,17 @@
   }
 
   @Test
+  public void testAllChangesDeletedFromIndexOK() throws Exception {
+    when(httpSessionMock.delete(eq(DELETE_ALL_CHANGES_ENDPOINT)))
+        .thenReturn(new HttpResult(SUCCESSFUL, EMPTY_MSG));
+    assertThat(
+            forwarder
+                .deleteAllChangesForProject(Project.nameKey(PROJECT_NAME))
+                .get(TEST_TIMEOUT, TEST_TIMEOUT_UNITS))
+        .isTrue();
+  }
+
+  @Test
   public void testChangeDeletedFromIndexFailed() throws Exception {
     when(httpSessionMock.delete(eq(DELETE_CHANGE_ENDPOINT)))
         .thenReturn(new HttpResult(FAILED, EMPTY_MSG));