OnlineNoteDbMIgrationIT: Tests for subsets, failed preconditions

Prefer MigrationException to unchecked exceptions, for a more
consistent interface, and to make tests slightly easier to write.

Change-Id: I2b2b7ff2b15c5b528ad1ab799cf41e137453f982
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
index f7331b8..181967d 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -18,40 +18,89 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.notedb.ConfigNotesMigration;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
 import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.notedb.rebuild.MigrationException;
 import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.testutil.NoteDbMode;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
 import org.junit.Before;
 import org.junit.Test;
 
 @Sandboxed
 @NoHttpd
 public class OnlineNoteDbMigrationIT extends AbstractDaemonTest {
+  // Tests in this class are generally interested in the actual ReviewDb contents, but the shifting
+  // migration state may result in various kinds of wrappers showing up unexpectedly.
+  @Inject @ReviewDbFactory private SchemaFactory<ReviewDb> schemaFactory;
+
+  @Inject private SitePaths sitePaths;
   @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
 
+  private FileBasedConfig gerritConfig;
+
   @Before
-  public void setUp() {
+  public void setUp() throws Exception {
     assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.OFF);
+    gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
     assertNotesMigrationState(NotesMigrationState.REVIEW_DB);
   }
 
   @Test
+  public void preconditionsFail() throws Exception {
+    List<Change.Id> cs = ImmutableList.of(new Change.Id(1));
+    List<Project.NameKey> ps = ImmutableList.of(new Project.NameKey("p"));
+    assertMigrationException(
+        "Cannot rebuild without noteDb.changes.write=true", b -> b, NoteDbMigrator::rebuild);
+    assertMigrationException(
+        "Cannot set both changes and projects", b -> b.setChanges(cs).setProjects(ps), m -> {});
+    assertMigrationException(
+        "Cannot set changes or projects during auto-migration",
+        b -> b.setChanges(cs),
+        NoteDbMigrator::migrate);
+    assertMigrationException(
+        "Cannot set changes or projects during auto-migration",
+        b -> b.setProjects(ps),
+        NoteDbMigrator::migrate);
+
+    setNotesMigrationState(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
+    assertMigrationException(
+        "Migration has already progressed past the endpoint of the \"trial mode\" state",
+        b -> b.setTrialMode(true),
+        NoteDbMigrator::migrate);
+
+    setNotesMigrationState(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
+    assertMigrationException(
+        "Cannot force rebuild changes; NoteDb is already the primary storage for some changes",
+        b -> b.setForceRebuild(true),
+        NoteDbMigrator::migrate);
+  }
+
+  @Test
   public void rebuildOneChangeTrialMode() throws Exception {
     PushOneCommit.Result r = createChange();
     Change.Id id = r.getChange().getId();
@@ -68,15 +117,115 @@
       metaId = ref.getObjectId();
     }
 
-    Change c = ReviewDbUtil.unwrapDb(db).changes().get(id);
-    assertThat(c).isNotNull();
-    NoteDbChangeState state = NoteDbChangeState.parse(c);
-    assertThat(state).isNotNull();
-    assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
-    assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c = db.changes().get(id);
+      assertThat(c).isNotNull();
+      NoteDbChangeState state = NoteDbChangeState.parse(c);
+      assertThat(state).isNotNull();
+      assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
+      assertThat(state.getRefState()).hasValue(RefState.create(metaId, ImmutableMap.of()));
+    }
   }
 
-  private void assertNotesMigrationState(NotesMigrationState expected) {
+  @Test
+  public void rebuildSubsetOfChanges() throws Exception {
+    setNotesMigrationState(NotesMigrationState.WRITE);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    String invalidState = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c1 = db.changes().get(id1);
+      c1.setNoteDbState(invalidState);
+      Change c2 = db.changes().get(id2);
+      c2.setNoteDbState(invalidState);
+      db.changes().update(ImmutableList.of(c1, c2));
+    }
+
+    try (NoteDbMigrator migrator =
+        migratorBuilderProvider.get().setChanges(ImmutableList.of(id2)).build()) {
+      migrator.rebuild();
+    }
+
+    try (ReviewDb db = schemaFactory.open()) {
+      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
+      assertThat(s1.getChangeMetaId().name()).isEqualTo(invalidState);
+
+      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
+      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(invalidState);
+    }
+  }
+
+  @Test
+  public void rebuildSubsetOfProjects() throws Exception {
+    setNotesMigrationState(NotesMigrationState.WRITE);
+
+    Project.NameKey p2 = createProject("project2");
+    TestRepository<?> tr2 = cloneProject(p2, admin);
+
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = pushFactory.create(db, admin.getIdent(), tr2).to("refs/for/master");
+    Change.Id id1 = r1.getChange().getId();
+    Change.Id id2 = r2.getChange().getId();
+
+    String invalidState = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c1 = db.changes().get(id1);
+      c1.setNoteDbState(invalidState);
+      Change c2 = db.changes().get(id2);
+      c2.setNoteDbState(invalidState);
+      db.changes().update(ImmutableList.of(c1, c2));
+    }
+
+    try (NoteDbMigrator migrator =
+        migratorBuilderProvider.get().setProjects(ImmutableList.of(p2)).build()) {
+      migrator.rebuild();
+    }
+
+    try (ReviewDb db = schemaFactory.open()) {
+      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
+      assertThat(s1.getChangeMetaId().name()).isEqualTo(invalidState);
+
+      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
+      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(invalidState);
+    }
+  }
+
+  private void assertNotesMigrationState(NotesMigrationState expected) throws Exception {
     assertThat(NotesMigrationState.forNotesMigration(notesMigration)).hasValue(expected);
+    gerritConfig.load();
+    assertThat(NotesMigrationState.forNotesMigration(new ConfigNotesMigration(gerritConfig)))
+        .hasValue(expected);
+  }
+
+  private void setNotesMigrationState(NotesMigrationState state) throws Exception {
+    gerritConfig.load();
+    ConfigNotesMigration.setConfigValues(gerritConfig, state.migration());
+    gerritConfig.save();
+    notesMigration.setFrom(state.migration());
+  }
+
+  @FunctionalInterface
+  interface PrepareBuilder {
+    NoteDbMigrator.Builder prepare(NoteDbMigrator.Builder b) throws Exception;
+  }
+
+  @FunctionalInterface
+  interface RunMigration {
+    void run(NoteDbMigrator m) throws Exception;
+  }
+
+  private void assertMigrationException(
+      String expectMessageContains, PrepareBuilder b, RunMigration m) throws Exception {
+    try {
+      try (NoteDbMigrator migrator = b.prepare(migratorBuilderProvider.get()).build()) {
+        m.run(migrator);
+      }
+    } catch (MigrationException e) {
+      assertThat(e).hasMessageThat().contains(expectMessageContains);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
index 209fd7a..0587b80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
@@ -17,7 +17,7 @@
 import java.io.IOException;
 
 /** Exception thrown by {@link NoteDbMigrator} when migration fails. */
-class MigrationException extends IOException {
+public class MigrationException extends IOException {
   private static final long serialVersionUID = 1L;
 
   MigrationException(String message) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index 8d43e0b..92ddd42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
 import static com.google.gerrit.server.notedb.NotesMigrationState.NOTE_DB_UNFUSED;
 import static com.google.gerrit.server.notedb.NotesMigrationState.READ_WRITE_NO_SEQUENCE;
@@ -197,7 +196,7 @@
       return this;
     }
 
-    public NoteDbMigrator build() {
+    public NoteDbMigrator build() throws MigrationException {
       return new NoteDbMigrator(
           sitePaths,
           schemaFactory,
@@ -236,17 +235,16 @@
       ImmutableList<Change.Id> changes,
       OutputStream progressOut,
       boolean trial,
-      boolean forceRebuild) {
+      boolean forceRebuild)
+      throws MigrationException {
+    if (!changes.isEmpty() && !projects.isEmpty()) {
+      throw new MigrationException("Cannot set both changes and projects");
+    }
+
     this.schemaFactory = schemaFactory;
     this.rebuilder = rebuilder;
     this.globalNotesMigration = globalNotesMigration;
-
-    boolean hasChanges = !changes.isEmpty();
-    boolean hasProjects = !projects.isEmpty();
-    checkArgument(!(hasChanges && hasProjects), "cannot set both changes and projects");
-
     this.gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
-
     this.executor = executor;
     this.projects = projects;
     this.changes = changes;
@@ -261,9 +259,10 @@
   }
 
   public void migrate() throws OrmException, IOException {
-    checkState(
-        changes.isEmpty() && projects.isEmpty(),
-        "cannot set changes or projects during auto-migration; call rebuild() instead");
+    if (!changes.isEmpty() || !projects.isEmpty()) {
+      throw new MigrationException(
+          "Cannot set changes or projects during auto-migration; call rebuild() instead");
+    }
     Optional<NotesMigrationState> maybeState = loadState();
     if (!maybeState.isPresent()) {
       throw new MigrationException("Could not determine initial migration state");
@@ -384,9 +383,9 @@
   }
 
   public void rebuild() throws MigrationException, OrmException {
-    checkState(
-        globalNotesMigration.commitChangeWrites(),
-        "cannot rebuild without noteDb.changes.write=true");
+    if (!globalNotesMigration.commitChangeWrites()) {
+      throw new MigrationException("Cannot rebuild without noteDb.changes.write=true");
+    }
     boolean ok;
     Stopwatch sw = Stopwatch.createStarted();
     log.info("Rebuilding changes in NoteDb");