Fix issue with change indexing during the NoteDb online migration

During the online migration to NoteDb with HA setup node which is
running the online migration can already read and write from/to NoteDb
instead of ReviewDb while the other node still writes only to ReviewDb.
If the second node receive write traffic indexing on the first node
will fail. To avoid this situation before indexing check if change is
migrated to NoteDb if not migrate it.

Bug: Issue 14725
Change-Id: I2474d16387fbbeb5b3c7624441e1cd6e50c61b41
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java
index a6cbb7f..bb1128a 100644
--- a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/ForwardedIndexBatchChangeHandler.java
@@ -34,7 +34,9 @@
       Configuration config,
       @ForwardedBatchIndexExecutor ScheduledExecutorService indexExecutor,
       OneOffRequestContext oneOffCtx,
-      Factory changeCheckerFactory) {
-    super(indexer, changeDb, config, indexExecutor, oneOffCtx, changeCheckerFactory);
+      Factory changeCheckerFactory,
+      SingleChangeNoteDbMigrator noteDbMigration) {
+    super(
+        indexer, changeDb, config, indexExecutor, oneOffCtx, changeCheckerFactory, noteDbMigration);
   }
 }
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 d7c7e88..d2c0f85 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
@@ -22,6 +22,7 @@
 import com.ericsson.gerrit.plugins.highavailability.index.ForwardedIndexExecutor;
 import com.google.common.base.Splitter;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -32,6 +33,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
@@ -51,6 +53,7 @@
   private final int retryInterval;
   private final int maxTries;
   private final ChangeCheckerImpl.Factory changeCheckerFactory;
+  private final SingleChangeNoteDbMigrator noteDbMigration;
 
   @Inject
   ForwardedIndexChangeHandler(
@@ -59,13 +62,15 @@
       Configuration config,
       @ForwardedIndexExecutor ScheduledExecutorService indexExecutor,
       OneOffRequestContext oneOffCtx,
-      ChangeCheckerImpl.Factory changeCheckerFactory) {
+      ChangeCheckerImpl.Factory changeCheckerFactory,
+      SingleChangeNoteDbMigrator noteDbMigration) {
     super(config.index());
     this.indexer = indexer;
     this.changeDb = changeDb;
     this.indexExecutor = indexExecutor;
     this.oneOffCtx = oneOffCtx;
     this.changeCheckerFactory = changeCheckerFactory;
+    this.noteDbMigration = noteDbMigration;
 
     Index indexConfig = config.index();
     this.retryInterval = indexConfig != null ? indexConfig.retryInterval() : 0;
@@ -75,7 +80,13 @@
   @Override
   protected void doIndex(String id, Optional<IndexEvent> indexEvent)
       throws IOException, OrmException {
-    doIndex(id, indexEvent, 0);
+    try {
+      noteDbMigration.migrate(parseChangeId(id), parseProject(id));
+      doIndex(id, indexEvent, 0);
+    } catch (SingleChangeNoteDbMigrationException e) {
+      log.atSevere().withCause(e).log(
+          "Migration for change %s, project %s " + "skipped.", parseChangeId(id), parseProject(id));
+    }
   }
 
   private void doIndex(String id, Optional<IndexEvent> indexEvent, int retryCount)
@@ -160,7 +171,15 @@
   }
 
   private static Change.Id parseChangeId(String id) {
-    return new Change.Id(Integer.parseInt(Splitter.on("~").splitToList(id).get(1)));
+    return new Change.Id(Integer.parseInt(getChangeIdParts(id).get(1)));
+  }
+
+  private static Project.NameKey parseProject(String id) {
+    return new Project.NameKey(getChangeIdParts(id).get(0));
+  }
+
+  private static List<String> getChangeIdParts(String id) {
+    return Splitter.on("~").splitToList(id);
   }
 
   private static boolean isCausedByNoSuchChangeException(Throwable throwable) {
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigrationException.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigrationException.java
new file mode 100644
index 0000000..3ae5e4f
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigrationException.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2021 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;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Arrays;
+
+public class SingleChangeNoteDbMigrationException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public SingleChangeNoteDbMigrationException(
+      Change.Id changeId, Project.NameKey project, Throwable t) {
+    super(
+        String.format(
+            "Error in NoteDb migration for change: %s," + " project: %s. %s",
+            changeId, project, Arrays.toString(t.getStackTrace())));
+  }
+}
diff --git a/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigrator.java b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigrator.java
new file mode 100644
index 0000000..0d12960
--- /dev/null
+++ b/src/main/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigrator.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2021 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;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+class SingleChangeNoteDbMigrator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+  private final NotesMigration migration;
+  private final GitRepositoryManager repoManager;
+  private boolean trial;
+
+  @Inject
+  SingleChangeNoteDbMigrator(
+      Provider<NoteDbMigrator.Builder> migratorBuilderProvider,
+      @GerritServerConfig Config cfg,
+      NotesMigration migration,
+      GitRepositoryManager repoManager) {
+    this.migratorBuilderProvider = migratorBuilderProvider;
+    this.migration = migration;
+    this.repoManager = repoManager;
+    this.trial = NoteDbMigrator.getTrialMode(cfg);
+  }
+
+  public void migrate(Change.Id id, NameKey project) throws SingleChangeNoteDbMigrationException {
+    if (migration.readChanges()
+        && !migration.disableChangeReviewDb()
+        && !metaRefExists(id, project)) {
+      try (NoteDbMigrator migrator =
+          migratorBuilderProvider
+              .get()
+              .setThreads(1)
+              .setAutoMigrate(true)
+              .setTrialMode(trial)
+              .setChanges(ImmutableSet.of(id))
+              .build()) {
+        migrator.rebuild();
+      } catch (Exception e) {
+        throw new SingleChangeNoteDbMigrationException(id, project, e);
+      }
+    }
+  }
+
+  private boolean metaRefExists(Id id, NameKey project)
+      throws SingleChangeNoteDbMigrationException {
+    try (Repository repo = repoManager.openRepository(project)) {
+      String metaRef = RefNames.changeMetaRef(id);
+      return repo.getRefDatabase().exactRef(metaRef) != null;
+    } catch (IOException e) {
+      throw new SingleChangeNoteDbMigrationException(id, project, e);
+    }
+  }
+}
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 6bfd47d..5e53a01 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
@@ -72,6 +72,7 @@
   @Mock private ChangeCheckerImpl.Factory changeCheckerFactoryMock;
   @Mock private ChangeChecker changeCheckerAbsentMock;
   @Mock private ChangeChecker changeCheckerPresentMock;
+  @Mock private SingleChangeNoteDbMigrator noteDbMigration;
   private ForwardedIndexChangeHandler handler;
   private Change.Id id;
 
@@ -91,7 +92,8 @@
             configMock,
             indexExecutorMock,
             ctxMock,
-            changeCheckerFactoryMock);
+            changeCheckerFactoryMock,
+            noteDbMigration);
   }
 
   @Test
diff --git a/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigratorTest.java b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigratorTest.java
new file mode 100644
index 0000000..4c24afd
--- /dev/null
+++ b/src/test/java/com/ericsson/gerrit/plugins/highavailability/forwarder/SingleChangeNoteDbMigratorTest.java
@@ -0,0 +1,133 @@
+// Copyright (C) 2021 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
+//
+package com.ericsson.gerrit.plugins.highavailability.forwarder;
+
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.rebuild.MigrationException;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class SingleChangeNoteDbMigratorTest {
+  @Mock Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+
+  @Mock(answer = Answers.RETURNS_SELF)
+  NoteDbMigrator.Builder migratorBuilder;
+
+  @Mock Config cfg;
+  @Mock GitRepositoryManager repoManager;
+  @Mock Repository repository;
+  @Mock RefDatabase refDatabase;
+  @Mock Ref ref;
+  @Mock NoteDbMigrator noteDbMigrator;
+
+  NotesMigration migration;
+  Change.Id id = new Change.Id(1);
+  Project.NameKey project = new Project.NameKey("test_project");
+  SingleChangeNoteDbMigrator objectUnderTest;
+
+  @Before
+  public void setUp() throws Exception {
+    when(migratorBuilderProvider.get()).thenReturn(migratorBuilder);
+    when(migratorBuilder.build()).thenReturn(noteDbMigrator);
+    when(repoManager.openRepository(any())).thenReturn(repository);
+    when(repository.getRefDatabase()).thenReturn(refDatabase);
+    when(cfg.getBoolean(anyString(), anyString(), anyString(), anyBoolean())).thenReturn(true);
+    when(refDatabase.exactRef(anyString())).thenReturn(null);
+
+    migration =
+        MutableNotesMigration.newDisabled().setReadChanges(true).setDisableChangeReviewDb(false);
+
+    objectUnderTest =
+        new SingleChangeNoteDbMigrator(migratorBuilderProvider, cfg, migration, repoManager);
+  }
+
+  @Test
+  public void shouldMigrateChange()
+      throws MigrationException, OrmException, SingleChangeNoteDbMigrationException {
+    objectUnderTest.migrate(id, project);
+    verify(noteDbMigrator, times(1)).rebuild();
+  }
+
+  @Test
+  public void shouldSkipMigrationWhenMetaRefExists()
+      throws OrmException, IOException, SingleChangeNoteDbMigrationException {
+    when(refDatabase.exactRef(anyString())).thenReturn(ref);
+    objectUnderTest.migrate(id, project);
+    verify(noteDbMigrator, never()).rebuild();
+  }
+
+  @Test
+  public void shouldSkipMigrationWhenReviewDbIsDisabled()
+      throws OrmException, IOException, SingleChangeNoteDbMigrationException {
+    migration =
+        MutableNotesMigration.newDisabled()
+            .setReadChanges(true)
+            .setChangePrimaryStorage(PrimaryStorage.NOTE_DB)
+            .setDisableChangeReviewDb(true);
+    objectUnderTest =
+        new SingleChangeNoteDbMigrator(migratorBuilderProvider, cfg, migration, repoManager);
+
+    objectUnderTest.migrate(id, project);
+    verify(noteDbMigrator, never()).rebuild();
+  }
+
+  @Test
+  public void shouldSkipMigrationWhenReadChangesIsFalse()
+      throws OrmException, IOException, SingleChangeNoteDbMigrationException {
+    migration =
+        MutableNotesMigration.newDisabled().setReadChanges(false).setDisableChangeReviewDb(false);
+    objectUnderTest =
+        new SingleChangeNoteDbMigrator(migratorBuilderProvider, cfg, migration, repoManager);
+
+    objectUnderTest.migrate(id, project);
+    verify(noteDbMigrator, never()).rebuild();
+  }
+
+  @Test
+  public void shouldNotSkipMigrationWhenNotInTrialMode()
+      throws OrmException, IOException, SingleChangeNoteDbMigrationException {
+    when(cfg.getBoolean(anyString(), anyString(), anyString(), anyBoolean())).thenReturn(false);
+    objectUnderTest =
+        new SingleChangeNoteDbMigrator(migratorBuilderProvider, cfg, migration, repoManager);
+
+    objectUnderTest.migrate(id, project);
+    verify(noteDbMigrator, times(1)).rebuild();
+  }
+}