Merge changes from topic "notedb-schema-upgrade"

* changes:
  Remove underlying ReviewDb from NoteDb wrappers
  Sequences: Remove ReviewDb support
  Ensure 2.16 upgrade is run before 3.x upgrade
  NoteDb schema upgrades
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 8a3f618..b73d57e 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -90,8 +90,8 @@
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.DatabaseModule;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gerrit.server.schema.ReviewDbSchemaModule;
-import com.google.gerrit.server.schema.ReviewDbSchemaVersionCheck;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
@@ -310,7 +310,7 @@
   private Injector createCfgInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(new ReviewDbSchemaModule());
-    modules.add(ReviewDbSchemaVersionCheck.module());
+    modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new AuthConfigModule());
     return dbInjector.createChildInjector(modules);
   }
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 33b3268..e480f77 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -97,7 +97,7 @@
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
-import com.google.gerrit.server.schema.ReviewDbSchemaVersionCheck;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
@@ -290,7 +290,6 @@
         JythonShell shell = new JythonShell();
         shell.set("m", manager);
         shell.set("ds", dbInjector.getInstance(DataSourceProvider.class));
-        shell.set("schk", dbInjector.getInstance(ReviewDbSchemaVersionCheck.class));
         shell.set("d", this);
         shell.run();
       } else {
@@ -397,7 +396,7 @@
 
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
-    modules.add(ReviewDbSchemaVersionCheck.module());
+    modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
 
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 6ebd6a3..feeaa27 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.schema.ReviewDbSchemaVersionCheck;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -55,7 +55,7 @@
   @Override
   public int run() throws Exception {
     Injector dbInjector = createDbInjector(MULTI_USER);
-    manager.add(dbInjector, dbInjector.createChildInjector(ReviewDbSchemaVersionCheck.module()));
+    manager.add(dbInjector, dbInjector.createChildInjector(NoteDbSchemaVersionCheck.module()));
     manager.start();
     dbInjector
         .createChildInjector(
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 71957e1..9a92408 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -42,6 +42,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.plugins.JarScanner;
+import com.google.gerrit.server.schema.NoteDbSchemaUpdater;
 import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.ReviewDbSchemaUpdater;
 import com.google.gerrit.server.schema.UpdateUI;
@@ -358,7 +359,8 @@
     public final ConsoleUI ui;
     public final SitePaths site;
     public final InitFlags flags;
-    final ReviewDbSchemaUpdater schemaUpdater;
+    final ReviewDbSchemaUpdater reviewDbSchemaUpdater;
+    final NoteDbSchemaUpdater noteDbSchemaUpdater;
     final SchemaFactory<ReviewDb> schema;
     final GitRepositoryManager repositoryManager;
 
@@ -367,57 +369,23 @@
         ConsoleUI ui,
         SitePaths site,
         InitFlags flags,
-        ReviewDbSchemaUpdater schemaUpdater,
+        ReviewDbSchemaUpdater reviewDbSchemaUpdater,
+        NoteDbSchemaUpdater noteDbSchemaUpdater,
         @ReviewDbFactory SchemaFactory<ReviewDb> schema,
         GitRepositoryManager repositoryManager) {
       this.ui = ui;
       this.site = site;
       this.flags = flags;
-      this.schemaUpdater = schemaUpdater;
+      this.reviewDbSchemaUpdater = reviewDbSchemaUpdater;
+      this.noteDbSchemaUpdater = noteDbSchemaUpdater;
       this.schema = schema;
       this.repositoryManager = repositoryManager;
     }
 
     void upgradeSchema() throws OrmException {
       final List<String> pruneList = new ArrayList<>();
-      schemaUpdater.update(
-          new UpdateUI() {
-            @Override
-            public void message(String message) {
-              System.err.println(message);
-              System.err.flush();
-            }
-
-            @Override
-            public boolean yesno(boolean defaultValue, String message) {
-              return ui.yesno(defaultValue, message);
-            }
-
-            @Override
-            public void waitForUser() {
-              ui.waitForUser();
-            }
-
-            @Override
-            public String readString(
-                String defaultValue, Set<String> allowedValues, String message) {
-              return ui.readString(defaultValue, allowedValues, message);
-            }
-
-            @Override
-            public boolean isBatch() {
-              return ui.isBatch();
-            }
-
-            @Override
-            public void pruneSchema(StatementExecutor e, List<String> prune) {
-              for (String p : prune) {
-                if (!pruneList.contains(p)) {
-                  pruneList.add(p);
-                }
-              }
-            }
-          });
+      UpdateUI uiImpl = new UpdateUIImpl(ui, pruneList);
+      reviewDbSchemaUpdater.update(uiImpl);
 
       if (!pruneList.isEmpty()) {
         StringBuilder msg = new StringBuilder();
@@ -442,6 +410,53 @@
           }
         }
       }
+
+      noteDbSchemaUpdater.update(uiImpl);
+    }
+
+    private static class UpdateUIImpl implements UpdateUI {
+      private final ConsoleUI consoleUi;
+      private final List<String> pruneList;
+
+      UpdateUIImpl(ConsoleUI consoleUi, List<String> pruneList) {
+        this.consoleUi = consoleUi;
+        this.pruneList = pruneList;
+      }
+
+      @Override
+      public void message(String message) {
+        System.err.println(message);
+        System.err.flush();
+      }
+
+      @Override
+      public boolean yesno(boolean defaultValue, String message) {
+        return consoleUi.yesno(defaultValue, message);
+      }
+
+      @Override
+      public void waitForUser() {
+        consoleUi.waitForUser();
+      }
+
+      @Override
+      public String readString(String defaultValue, Set<String> allowedValues, String message) {
+        return consoleUi.readString(defaultValue, allowedValues, message);
+      }
+
+      @Override
+      public boolean isBatch() {
+        return consoleUi.isBatch();
+      }
+
+      @Override
+      public void pruneSchema(StatementExecutor e, List<String> prune) {
+        for (String p : prune) {
+          if (!pruneList.contains(p)) {
+            pruneList.add(p);
+          }
+        }
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/reviewdb/client/RefNames.java b/java/com/google/gerrit/reviewdb/client/RefNames.java
index fd2fb56..5e45088 100644
--- a/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -49,6 +49,9 @@
   /** Sequence counters in NoteDb. */
   public static final String REFS_SEQUENCES = "refs/sequences/";
 
+  /** NoteDb schema version number. */
+  public static final String REFS_VERSION = "refs/meta/version";
+
   /**
    * Prefix applied to merge commit base nodes.
    *
diff --git a/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java b/java/com/google/gerrit/reviewdb/server/DisallowedReviewDb.java
similarity index 97%
rename from java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
rename to java/com/google/gerrit/reviewdb/server/DisallowedReviewDb.java
index fdf3d6c..60c3c95 100644
--- a/java/com/google/gerrit/reviewdb/server/DisallowReadFromChangesReviewDbWrapper.java
+++ b/java/com/google/gerrit/reviewdb/server/DisallowedReviewDb.java
@@ -23,7 +23,7 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 
-public class DisallowReadFromChangesReviewDbWrapper extends ReviewDbWrapper {
+public class DisallowedReviewDb extends ReviewDbWrapper {
   private static final String MSG = "This table has been migrated to NoteDb";
 
   private final Changes changes;
@@ -32,7 +32,7 @@
   private final PatchSets patchSets;
   private final PatchLineComments patchComments;
 
-  public DisallowReadFromChangesReviewDbWrapper(ReviewDb db) {
+  public DisallowedReviewDb(ReviewDb db) {
     super(db);
     changes = new Changes(delegate.changes());
     patchSetApprovals = new PatchSetApprovals(delegate.patchSetApprovals());
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
index aed9778..1f0f12f 100644
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbUtil.java
@@ -50,8 +50,8 @@
   }
 
   public static ReviewDb unwrapDb(ReviewDb db) {
-    if (db instanceof DisallowReadFromChangesReviewDbWrapper) {
-      return unwrapDb(((DisallowReadFromChangesReviewDbWrapper) db).unsafeGetDelegate());
+    if (db instanceof DisallowedReviewDb) {
+      return unwrapDb(((DisallowedReviewDb) db).unsafeGetDelegate());
     }
     return db;
   }
diff --git a/java/com/google/gerrit/server/Sequences.java b/java/com/google/gerrit/server/Sequences.java
index 70a02a8..8381b5c 100644
--- a/java/com/google/gerrit/server/Sequences.java
+++ b/java/com/google/gerrit/server/Sequences.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkArgument;
-
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.metrics.Description;
@@ -29,14 +27,10 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -55,8 +49,6 @@
     GROUPS;
   }
 
-  private final Provider<ReviewDb> db;
-  private final NotesMigration migration;
   private final RepoSequence accountSeq;
   private final RepoSequence changeSeq;
   private final RepoSequence groupSeq;
@@ -65,15 +57,11 @@
   @Inject
   public Sequences(
       @GerritServerConfig Config cfg,
-      Provider<ReviewDb> db,
-      NotesMigration migration,
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
       AllProjectsName allProjects,
       AllUsersName allUsers,
       MetricMaker metrics) {
-    this.db = db;
-    this.migration = migration;
 
     int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
     accountSeq =
@@ -85,13 +73,15 @@
             () -> ReviewDb.FIRST_ACCOUNT_ID,
             accountBatchSize);
 
-    int gap = getChangeSequenceGap(cfg);
-    @SuppressWarnings("deprecation")
-    RepoSequence.Seed changeSeed = () -> db.get().nextChangeId() + gap;
     int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
     changeSeq =
         new RepoSequence(
-            repoManager, gitRefUpdated, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
+            repoManager,
+            gitRefUpdated,
+            allProjects,
+            NAME_CHANGES,
+            () -> ReviewDb.FIRST_CHANGE_ID,
+            changeBatchSize);
 
     int groupBatchSize = 1;
     groupSeq =
@@ -120,31 +110,15 @@
   }
 
   public int nextChangeId() throws OrmException {
-    if (!migration.readChangeSequence()) {
-      return nextChangeId(db.get());
-    }
     try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, false)) {
       return changeSeq.next();
     }
   }
 
   public ImmutableList<Integer> nextChangeIds(int count) throws OrmException {
-    if (migration.readChangeSequence()) {
-      try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
-        return changeSeq.next(count);
-      }
+    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
+      return changeSeq.next(count);
     }
-
-    if (count == 0) {
-      return ImmutableList.of();
-    }
-    checkArgument(count > 0, "count is negative: %s", count);
-    List<Integer> ids = new ArrayList<>(count);
-    ReviewDb db = this.db.get();
-    for (int i = 0; i < count; i++) {
-      ids.add(nextChangeId(db));
-    }
-    return ImmutableList.copyOf(ids);
   }
 
   public int nextGroupId() throws OrmException {
@@ -157,9 +131,4 @@
   public RepoSequence getChangeIdRepoSequence() {
     return changeSeq;
   }
-
-  @SuppressWarnings("deprecation")
-  private static int nextChangeId(ReviewDb db) throws OrmException {
-    return db.nextChangeId();
-  }
 }
diff --git a/java/com/google/gerrit/server/notedb/NoteDbSchemaVersionManager.java b/java/com/google/gerrit/server/notedb/NoteDbSchemaVersionManager.java
new file mode 100644
index 0000000..a8355c3
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/NoteDbSchemaVersionManager.java
@@ -0,0 +1,101 @@
+// Copyright (C) 2018 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.google.gerrit.server.notedb;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_VERSION;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.Optional;
+import javax.inject.Inject;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class NoteDbSchemaVersionManager {
+  private final AllProjectsName allProjectsName;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  @VisibleForTesting
+  public NoteDbSchemaVersionManager(
+      AllProjectsName allProjectsName, GitRepositoryManager repoManager) {
+    // Can't inject GitReferenceUpdated here because it has dependencies that are not always
+    // available in this injector (e.g. during init). This is ok for now since no other ref updates
+    // during init are available to plugins, and there are not any other use cases for listening for
+    // updates to the version ref.
+    this.allProjectsName = allProjectsName;
+    this.repoManager = repoManager;
+  }
+
+  public int read() throws OrmException {
+    try (Repository repo = repoManager.openRepository(allProjectsName)) {
+      return IntBlob.parse(repo, REFS_VERSION).map(IntBlob::value).orElse(0);
+    } catch (IOException e) {
+      throw new OrmException("Failed to read " + REFS_VERSION, e);
+    }
+  }
+
+  public void init() throws IOException, OrmException {
+    try (Repository repo = repoManager.openRepository(allProjectsName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> old = IntBlob.parse(repo, REFS_VERSION, rw);
+      if (old.isPresent()) {
+        throw new OrmException(
+            String.format(
+                "Expected no old version for %s, found %s", REFS_VERSION, old.get().value()));
+      }
+      IntBlob.store(
+          repo,
+          rw,
+          allProjectsName,
+          REFS_VERSION,
+          old.map(IntBlob::id).orElse(ObjectId.zeroId()),
+          // TODO(dborowitz): Find some way to not hard-code this constant here. We can't depend on
+          // NoteDbSchemaVersions from this package, because the schema java_library depends on the
+          // server java_library, so that would add a circular dependency. But *this* class must
+          // live in the server library, because it's used by things like NoteDbMigrator. One
+          // option: once NoteDbMigrator goes away, this class could move back to the schema
+          // subpackage.
+          180,
+          GitReferenceUpdated.DISABLED);
+    }
+  }
+
+  public void increment(int expectedOldVersion) throws IOException, OrmException {
+    try (Repository repo = repoManager.openRepository(allProjectsName);
+        RevWalk rw = new RevWalk(repo)) {
+      Optional<IntBlob> old = IntBlob.parse(repo, REFS_VERSION, rw);
+      if (old.isPresent() && old.get().value() != expectedOldVersion) {
+        throw new OrmException(
+            String.format(
+                "Expected old version %d for %s, found %d",
+                expectedOldVersion, REFS_VERSION, old.get().value()));
+      }
+      IntBlob.store(
+          repo,
+          rw,
+          allProjectsName,
+          REFS_VERSION,
+          old.map(IntBlob::id).orElse(ObjectId.zeroId()),
+          expectedOldVersion + 1,
+          GitReferenceUpdated.DISABLED);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/NotesMigration.java b/java/com/google/gerrit/server/notedb/NotesMigration.java
index 9cee2cd..c7acaf1 100644
--- a/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -57,9 +57,8 @@
   public static final String READ = "read";
   public static final String WRITE = "write";
   public static final String DISABLE_REVIEW_DB = "disableReviewDb";
-
-  private static final String PRIMARY_STORAGE = "primaryStorage";
-  private static final String SEQUENCE = "sequence";
+  public static final String PRIMARY_STORAGE = "primaryStorage";
+  public static final String SEQUENCE = "sequence";
 
   public static class Module extends AbstractModule {
     @Override
diff --git a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
index ff935d8..f60744f 100644
--- a/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
+++ b/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -64,6 +64,7 @@
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NoteDbSchemaVersionManager;
 import com.google.gerrit.server.notedb.NoteDbTable;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NotesMigrationState;
@@ -151,6 +152,7 @@
     private final MutableNotesMigration globalNotesMigration;
     private final PrimaryStorageMigrator primaryStorageMigrator;
     private final PluginSetContext<NotesMigrationStateListener> listeners;
+    private final NoteDbSchemaVersionManager versionManager;
 
     private int threads;
     private ImmutableList<Project.NameKey> projects = ImmutableList.of();
@@ -179,7 +181,8 @@
         WorkQueue workQueue,
         MutableNotesMigration globalNotesMigration,
         PrimaryStorageMigrator primaryStorageMigrator,
-        PluginSetContext<NotesMigrationStateListener> listeners) {
+        PluginSetContext<NotesMigrationStateListener> listeners,
+        NoteDbSchemaVersionManager versionManager) {
       // Reload gerrit.config/notedb.config on each migrator invocation, in case a previous
       // migration in the same process modified the on-disk contents. This ensures the defaults for
       // trial/autoMigrate get set correctly below.
@@ -199,6 +202,7 @@
       this.globalNotesMigration = globalNotesMigration;
       this.primaryStorageMigrator = primaryStorageMigrator;
       this.listeners = listeners;
+      this.versionManager = versionManager;
       this.trial = getTrialMode(cfg);
       this.autoMigrate = getAutoMigrate(cfg);
     }
@@ -361,6 +365,7 @@
           globalNotesMigration,
           primaryStorageMigrator,
           listeners,
+          versionManager,
           threads > 1
               ? MoreExecutors.listeningDecorator(
                   workQueue.createQueue(threads, "RebuildChange", true))
@@ -391,6 +396,7 @@
   private final MutableNotesMigration globalNotesMigration;
   private final PrimaryStorageMigrator primaryStorageMigrator;
   private final PluginSetContext<NotesMigrationStateListener> listeners;
+  private final NoteDbSchemaVersionManager versionManager;
 
   private final ListeningExecutorService executor;
   private final ImmutableList<Project.NameKey> projects;
@@ -417,6 +423,7 @@
       MutableNotesMigration globalNotesMigration,
       PrimaryStorageMigrator primaryStorageMigrator,
       PluginSetContext<NotesMigrationStateListener> listeners,
+      NoteDbSchemaVersionManager versionManager,
       ListeningExecutorService executor,
       ImmutableList<Project.NameKey> projects,
       ImmutableList<Change.Id> changes,
@@ -447,6 +454,7 @@
     this.globalNotesMigration = globalNotesMigration;
     this.primaryStorageMigrator = primaryStorageMigrator;
     this.listeners = listeners;
+    this.versionManager = versionManager;
     this.executor = executor;
     this.projects = projects;
     this.changes = changes;
@@ -546,7 +554,9 @@
     }
   }
 
-  private NotesMigrationState turnOnWrites(NotesMigrationState prev) throws IOException {
+  private NotesMigrationState turnOnWrites(NotesMigrationState prev)
+      throws OrmException, IOException {
+    versionManager.init();
     return saveState(prev, WRITE);
   }
 
diff --git a/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
index 17eb56e..7ebc741 100644
--- a/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
+++ b/java/com/google/gerrit/server/schema/AbstractDisabledAccess.java
@@ -14,11 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
-import static com.google.common.base.Preconditions.checkState;
-
 import com.google.common.collect.ImmutableList;
 import com.google.common.util.concurrent.Futures;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
 import com.google.gwtorm.client.Key;
 import com.google.gwtorm.server.Access;
 import com.google.gwtorm.server.AtomicUpdate;
@@ -26,9 +23,10 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import java.util.Map;
-import java.util.function.Function;
 
 abstract class AbstractDisabledAccess<T, K extends Key<?>> implements Access<T, K> {
+  private static final String GONE = "ReviewDb is gone";
+
   private static <T> ResultSet<T> empty() {
     return new ListResultSet<>(ImmutableList.of());
   }
@@ -39,40 +37,30 @@
     return Futures.immediateCheckedFuture(null);
   }
 
-  // Don't even hold a reference to delegate, so it's not possible to use it
-  // accidentally.
-  private final ReviewDbWrapper wrapper;
-  private final String relationName;
-  private final int relationId;
-  private final Function<T, K> primaryKey;
-  private final Function<Iterable<T>, Map<K, T>> toMap;
+  private final NoChangesReviewDb wrapper;
 
-  AbstractDisabledAccess(ReviewDbWrapper wrapper, Access<T, K> delegate) {
+  AbstractDisabledAccess(NoChangesReviewDb wrapper) {
     this.wrapper = wrapper;
-    this.relationName = delegate.getRelationName();
-    this.relationId = delegate.getRelationID();
-    this.primaryKey = delegate::primaryKey;
-    this.toMap = delegate::toMap;
   }
 
   @Override
   public final int getRelationID() {
-    return relationId;
+    throw new UnsupportedOperationException(GONE);
   }
 
   @Override
   public final String getRelationName() {
-    return relationName;
+    throw new UnsupportedOperationException(GONE);
   }
 
   @Override
   public final K primaryKey(T entity) {
-    return primaryKey.apply(entity);
+    throw new UnsupportedOperationException(GONE);
   }
 
   @Override
   public final Map<K, T> toMap(Iterable<T> iterable) {
-    return toMap.apply(iterable);
+    throw new UnsupportedOperationException(GONE);
   }
 
   @Override
@@ -118,15 +106,7 @@
 
   @Override
   public final void beginTransaction(K key) {
-    // Keep track of when we've started a transaction so that we can avoid calling commit/rollback
-    // on the underlying ReviewDb. This is just a simple arm's-length approach, and may produce
-    // slightly different results from a native ReviewDb in corner cases like:
-    // * beginning transactions on different tables simultaneously
-    // * doing work between commit and rollback
-    // These kinds of things are already misuses of ReviewDb, and shouldn't be happening in current
-    // code anyway.
-    checkState(!wrapper.inTransaction(), "already in transaction");
-    wrapper.beginTransaction();
+    // Do nothing.
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 85965ef..14da9eb 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -48,9 +48,11 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.notedb.NoteDbSchemaVersionManager;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.RepoSequence;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -73,6 +75,7 @@
   private final AllProjectsName allProjectsName;
   private final PersonIdent serverUser;
   private final NotesMigration notesMigration;
+  private final NoteDbSchemaVersionManager versionManager;
   private final ProjectConfig.Factory projectConfigFactory;
   private final GroupReference anonymous;
   private final GroupReference registered;
@@ -91,12 +94,14 @@
       AllProjectsName allProjectsName,
       @GerritPersonIdent PersonIdent serverUser,
       NotesMigration notesMigration,
+      NoteDbSchemaVersionManager versionManager,
       SystemGroupBackend systemGroupBackend,
       ProjectConfig.Factory projectConfigFactory) {
     this.repositoryManager = repositoryManager;
     this.allProjectsName = allProjectsName;
     this.serverUser = serverUser;
     this.notesMigration = notesMigration;
+    this.versionManager = versionManager;
     this.projectConfigFactory = projectConfigFactory;
 
     this.anonymous = systemGroupBackend.getGroup(ANONYMOUS_USERS);
@@ -145,7 +150,7 @@
     return this;
   }
 
-  public void create() throws IOException, ConfigInvalidException {
+  public void create() throws IOException, ConfigInvalidException, OrmException {
     try (Repository git = repositoryManager.openRepository(allProjectsName)) {
       initAllProjects(git);
     } catch (RepositoryNotFoundException notFound) {
@@ -162,7 +167,8 @@
     }
   }
 
-  private void initAllProjects(Repository git) throws IOException, ConfigInvalidException {
+  private void initAllProjects(Repository git)
+      throws IOException, ConfigInvalidException, OrmException {
     BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
     try (MetaDataUpdate md =
         new MetaDataUpdate(GitReferenceUpdated.DISABLED, allProjectsName, git, bru)) {
@@ -231,6 +237,7 @@
 
       config.commitToNewRef(md, RefNames.REFS_CONFIG);
       initSequences(git, bru);
+      initSchemaVersion();
       execute(git, bru);
     }
   }
@@ -268,6 +275,12 @@
     }
   }
 
+  private void initSchemaVersion() throws IOException, OrmException {
+    if (notesMigration.commitChangeWrites()) {
+      versionManager.init();
+    }
+  }
+
   private void execute(Repository git, BatchRefUpdate bru) throws IOException {
     try (RevWalk rw = new RevWalk(git)) {
       bru.execute(rw, NullProgressMonitor.INSTANCE);
diff --git a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/java/com/google/gerrit/server/schema/NoChangesReviewDb.java
similarity index 76%
rename from java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
rename to java/com/google/gerrit/server/schema/NoChangesReviewDb.java
index 7247490..083ecd1 100644
--- a/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ b/java/com/google/gerrit/server/schema/NoChangesReviewDb.java
@@ -27,17 +27,21 @@
 import com.google.gerrit.reviewdb.server.PatchSetAccess;
 import com.google.gerrit.reviewdb.server.PatchSetApprovalAccess;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gerrit.reviewdb.server.SchemaVersionAccess;
+import com.google.gwtorm.server.Access;
 import com.google.gwtorm.server.ListResultSet;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.gwtorm.server.StatementExecutor;
 
 /**
  * Wrapper for ReviewDb that never calls the underlying change tables.
  *
  * <p>See {@link NotesMigrationSchemaFactory} for discussion.
  */
-class NoChangesReviewDbWrapper extends ReviewDbWrapper {
+class NoChangesReviewDb implements ReviewDb {
+  private static final String GONE = "ReviewDb is gone";
+
   private static <T> ResultSet<T> empty() {
     return new ListResultSet<>(ImmutableList.of());
   }
@@ -48,13 +52,17 @@
   private final PatchSetAccess patchSets;
   private final PatchLineCommentAccess patchComments;
 
-  NoChangesReviewDbWrapper(ReviewDb db) {
-    super(db);
-    changes = new Changes(this, delegate);
-    patchSetApprovals = new PatchSetApprovals(this, delegate);
-    changeMessages = new ChangeMessages(this, delegate);
-    patchSets = new PatchSets(this, delegate);
-    patchComments = new PatchLineComments(this, delegate);
+  NoChangesReviewDb() {
+    changes = new Changes(this);
+    patchSetApprovals = new PatchSetApprovals(this);
+    changeMessages = new ChangeMessages(this);
+    patchSets = new PatchSets(this);
+    patchComments = new PatchLineComments(this);
+  }
+
+  @Override
+  public SchemaVersionAccess schemaVersion() {
+    throw new UnsupportedOperationException(GONE);
   }
 
   @Override
@@ -82,10 +90,39 @@
     return patchComments;
   }
 
+  @Override
+  public int nextChangeId() {
+    throw new UnsupportedOperationException(GONE);
+  }
+
+  @Override
+  public void commit() {}
+
+  @Override
+  public void rollback() {}
+
+  @Override
+  public void updateSchema(StatementExecutor e) {
+    throw new UnsupportedOperationException(GONE);
+  }
+
+  @Override
+  public void pruneSchema(StatementExecutor e) {
+    throw new UnsupportedOperationException(GONE);
+  }
+
+  @Override
+  public Access<?, ?>[] allRelations() {
+    throw new UnsupportedOperationException(GONE);
+  }
+
+  @Override
+  public void close() {}
+
   private static class Changes extends AbstractDisabledAccess<Change, Change.Id>
       implements ChangeAccess {
-    private Changes(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.changes());
+    private Changes(NoChangesReviewDb wrapper) {
+      super(wrapper);
     }
 
     @Override
@@ -97,8 +134,8 @@
   private static class ChangeMessages
       extends AbstractDisabledAccess<ChangeMessage, ChangeMessage.Key>
       implements ChangeMessageAccess {
-    private ChangeMessages(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.changeMessages());
+    private ChangeMessages(NoChangesReviewDb wrapper) {
+      super(wrapper);
     }
 
     @Override
@@ -119,8 +156,8 @@
 
   private static class PatchSets extends AbstractDisabledAccess<PatchSet, PatchSet.Id>
       implements PatchSetAccess {
-    private PatchSets(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchSets());
+    private PatchSets(NoChangesReviewDb wrapper) {
+      super(wrapper);
     }
 
     @Override
@@ -137,8 +174,8 @@
   private static class PatchSetApprovals
       extends AbstractDisabledAccess<PatchSetApproval, PatchSetApproval.Key>
       implements PatchSetApprovalAccess {
-    private PatchSetApprovals(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchSetApprovals());
+    private PatchSetApprovals(NoChangesReviewDb wrapper) {
+      super(wrapper);
     }
 
     @Override
@@ -165,8 +202,8 @@
   private static class PatchLineComments
       extends AbstractDisabledAccess<PatchLineComment, PatchLineComment.Key>
       implements PatchLineCommentAccess {
-    private PatchLineComments(NoChangesReviewDbWrapper wrapper, ReviewDb db) {
-      super(wrapper, db.patchComments());
+    private PatchLineComments(NoChangesReviewDb wrapper) {
+      super(wrapper);
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
new file mode 100644
index 0000000..436b67f
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaUpdater.java
@@ -0,0 +1,187 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.PRIMARY_STORAGE;
+import static com.google.gerrit.server.notedb.NotesMigration.READ;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+import static com.google.gerrit.server.notedb.NotesMigration.WRITE;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NoteDbSchemaVersionManager;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+
+public class NoteDbSchemaUpdater {
+  private final Config cfg;
+  private final AllUsersName allUsersName;
+  private final GitRepositoryManager repoManager;
+  private final NotesMigration notesMigration;
+  private final NoteDbSchemaVersionManager versionManager;
+  private final NoteDbSchemaVersion.Arguments args;
+  private final ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> schemaVersions;
+
+  @Inject
+  NoteDbSchemaUpdater(
+      @GerritServerConfig Config cfg,
+      NotesMigration notesMigration,
+      NoteDbSchemaVersionManager versionManager,
+      NoteDbSchemaVersion.Arguments args,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName) {
+    this(
+        cfg,
+        allUsersName,
+        repoManager,
+        notesMigration,
+        versionManager,
+        args,
+        NoteDbSchemaVersions.ALL);
+  }
+
+  NoteDbSchemaUpdater(
+      Config cfg,
+      AllUsersName allUsersName,
+      GitRepositoryManager repoManager,
+      NotesMigration notesMigration,
+      NoteDbSchemaVersionManager versionManager,
+      NoteDbSchemaVersion.Arguments args,
+      ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> schemaVersions) {
+    this.cfg = cfg;
+    this.allUsersName = allUsersName;
+    this.repoManager = repoManager;
+    this.notesMigration = notesMigration;
+    this.versionManager = versionManager;
+    this.args = args;
+    this.schemaVersions = schemaVersions;
+  }
+
+  public void update(UpdateUI ui) throws OrmException {
+    if (!notesMigration.commitChangeWrites()) {
+      // TODO(dborowitz): Only necessary to make migration tests pass; remove when NoteDb is the
+      // only option.
+      return;
+    }
+    int currentVersion = versionManager.read();
+    if (currentVersion == 0) {
+      // The only valid case where there is no refs/meta/version is when running 3.x init for the
+      // first time on a site that previously ran init on 2.16. A freshly created 3.x site will have
+      // seeded refs/meta/version during AllProjectsCreator, so it won't hit this block.
+      checkNoteDbConfigFor216();
+    }
+
+    for (int nextVersion : requiredUpgrades(currentVersion, schemaVersions.keySet())) {
+      try {
+        ui.message(String.format("Migrating data to schema %d ...", nextVersion));
+        NoteDbSchemaVersions.get(schemaVersions, nextVersion, args).upgrade(ui);
+        versionManager.increment(nextVersion - 1);
+      } catch (Exception e) {
+        throw new OrmException(
+            String.format("Failed to upgrade to schema version %d", nextVersion), e);
+      }
+    }
+  }
+
+  private void checkNoteDbConfigFor216() throws OrmException {
+    // Check that the NoteDb migration config matches what we expect from a site that both:
+    // * Completed the change migration to NoteDB.
+    // * Ran schema upgrades from a 2.16 final release.
+
+    if (!cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false)
+        || !cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false)
+        || cfg.getEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB)
+            != PrimaryStorage.NOTE_DB
+        || !cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false)) {
+      throw new OrmException(
+          "You appear to be upgrading from a 2.x site, but the NoteDb change migration was"
+              + " not completed. See documentation:\n"
+              + "https://gerrit-review.googlesource.com/Documentation/note-db.html#migration");
+    }
+
+    // We don't have a direct way to check that 2.16 init was run; the most obvious side effect
+    // would be upgrading the *ReviewDb* schema to the latest 2.16 schema version. But in 3.x we can
+    // no longer access ReviewDb, so we can't check that directly.
+    //
+    // Instead, check for a NoteDb-specific side effect of the migration process: the presence of
+    // the NoteDb group sequence ref. This is created by the schema 163 migration, which was part of
+    // 2.16 and not 2.15.
+    //
+    // There are a few corner cases where we will proceed even if the schema is not fully up to
+    // date:
+    //  * If a user happened to run init from master after schema 163 was added but before 2.16
+    //    final. We assume that someone savvy enough to do that has followed the documented
+    //    requirement of upgrading to 2.16 final before 3.0.
+    //  * If a user ran init in 2.16.x and the upgrade to 163 succeeded but a later update failed.
+    //    In this case the server literally will not start under 2.16. We assume the user will fix
+    //    this and get 2.16 running rather than abandoning 2.16 and jumping to 3.0 at this point.
+    try (Repository allUsers = repoManager.openRepository(allUsersName)) {
+      if (allUsers.exactRef(RefNames.REFS_SEQUENCES + Sequences.NAME_GROUPS) == null) {
+        throw new OrmException(
+            "You appear to be upgrading to 3.x from a version prior to 2.16; you must upgrade to"
+                + " 2.16.x first");
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to check NoteDb migration state", e);
+    }
+  }
+
+  @VisibleForTesting
+  static ImmutableList<Integer> requiredUpgrades(
+      int currentVersion, ImmutableSortedSet<Integer> allVersions) throws OrmException {
+    int firstVersion = allVersions.first();
+    int latestVersion = allVersions.last();
+    if (currentVersion == latestVersion) {
+      return ImmutableList.of();
+    } else if (currentVersion > latestVersion) {
+      throw new OrmException(
+          String.format(
+              "Cannot downgrade NoteDb schema from version %d to %d",
+              currentVersion, latestVersion));
+    }
+
+    int firstUpgradeVersion;
+    if (currentVersion == 0) {
+      // Bootstrap NoteDb version to minimum supported schema number.
+      firstUpgradeVersion = firstVersion;
+    } else {
+      if (currentVersion < firstVersion - 1) {
+        throw new OrmException(
+            String.format(
+                "Cannot skip NoteDb schema from version %d to %d", currentVersion, firstVersion));
+      }
+      firstUpgradeVersion = currentVersion + 1;
+    }
+    return IntStream.rangeClosed(firstUpgradeVersion, latestVersion)
+        .boxed()
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
new file mode 100644
index 0000000..e90b2b8
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersion.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/**
+ * Schema upgrade implementation.
+ *
+ * <p>Implementations must define a single public constructor that takes an {@link Arguments}. The
+ * recommended idiom is to pull out whichever individual fields from the {@code Arguments} are
+ * required by this implementation.
+ */
+interface NoteDbSchemaVersion {
+  @Singleton
+  class Arguments {
+    final GitRepositoryManager repoManager;
+    final AllProjectsName allProjects;
+
+    @Inject
+    Arguments(GitRepositoryManager repoManager, AllProjectsName allProjects) {
+      this.repoManager = repoManager;
+      this.allProjects = allProjects;
+    }
+  }
+
+  void upgrade(UpdateUI ui) throws Exception;
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
new file mode 100644
index 0000000..9e26ee9
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersionCheck.java
@@ -0,0 +1,89 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.notedb.NoteDbSchemaVersionManager;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.ProvisionException;
+
+public class NoteDbSchemaVersionCheck implements LifecycleListener {
+  public static Module module() {
+    return new LifecycleModule() {
+      @Override
+      protected void configure() {
+        listener().to(NoteDbSchemaVersionCheck.class);
+      }
+    };
+  }
+
+  private final NotesMigration notesMigration;
+  private final NoteDbSchemaVersionManager versionManager;
+  private final SitePaths sitePaths;
+
+  @Inject
+  NoteDbSchemaVersionCheck(
+      NotesMigration notesMigration,
+      NoteDbSchemaVersionManager versionManager,
+      SitePaths sitePaths) {
+    this.notesMigration = notesMigration;
+    this.versionManager = versionManager;
+    this.sitePaths = sitePaths;
+  }
+
+  @Override
+  public void start() {
+    if (!notesMigration.commitChangeWrites()) {
+      // TODO(dborowitz): Only necessary to make migration tests pass; remove when NoteDb is the
+      // only option.
+      return;
+    }
+    try {
+      int current = versionManager.read();
+      if (current == 0) {
+        throw new ProvisionException(
+            String.format(
+                "Schema not yet initialized. Run init to initialize the schema:\n"
+                    + "$ java -jar gerrit.war init -d %s",
+                sitePaths.site_path.toAbsolutePath()));
+      }
+      int expected = NoteDbSchemaVersions.LATEST;
+      if (current != expected) {
+        String advice =
+            current > expected
+                ? "Downgrade is not supported"
+                : String.format(
+                    "Run init to upgrade:\n$ java -jar %s init -d %s",
+                    sitePaths.gerrit_war.toAbsolutePath(), sitePaths.site_path.toAbsolutePath());
+        throw new ProvisionException(
+            String.format(
+                "Unsupported schema version %d; expected schema version %d. %s",
+                current, expected, advice));
+      }
+    } catch (OrmException e) {
+      throw new ProvisionException("Failed to read NoteDb schema version", e);
+    }
+  }
+
+  @Override
+  public void stop() {
+    // Do nothing.
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
new file mode 100644
index 0000000..ffd4760
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/NoteDbSchemaVersions.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableSortedMap.toImmutableSortedMap;
+import static java.util.Comparator.naturalOrder;
+
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.server.UsedAt;
+import java.lang.reflect.InvocationTargetException;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+public class NoteDbSchemaVersions {
+  static final ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> ALL =
+      // List all supported NoteDb schema versions here.
+      Stream.of(Schema_180.class)
+          .collect(toImmutableSortedMap(naturalOrder(), v -> guessVersion(v).get(), v -> v));
+
+  public static final int FIRST = ALL.firstKey();
+  public static final int LATEST = ALL.lastKey();
+
+  // TODO(dborowitz): Migrate delete-project plugin to use this implementation.
+  @UsedAt(UsedAt.Project.PLUGIN_DELETE_PROJECT)
+  public static Optional<Integer> guessVersion(Class<?> c) {
+    String prefix = "Schema_";
+    if (!c.getSimpleName().startsWith(prefix)) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(Ints.tryParse(c.getSimpleName().substring(prefix.length())));
+  }
+
+  public static NoteDbSchemaVersion get(
+      ImmutableSortedMap<Integer, Class<? extends NoteDbSchemaVersion>> schemaVersions,
+      int i,
+      NoteDbSchemaVersion.Arguments args) {
+    Class<? extends NoteDbSchemaVersion> clazz = schemaVersions.get(i);
+    checkArgument(clazz != null, "Schema version not found: %s", i);
+    try {
+      return clazz.getDeclaredConstructor(NoteDbSchemaVersion.Arguments.class).newInstance(args);
+    } catch (InstantiationException
+        | IllegalAccessException
+        | NoSuchMethodException
+        | InvocationTargetException e) {
+      throw new IllegalStateException("failed to invoke constructor on " + clazz.getName(), e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
index 0d95610..c533619 100644
--- a/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
+++ b/java/com/google/gerrit/server/schema/NotesMigrationSchemaFactory.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.server.DisallowReadFromChangesReviewDbWrapper;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.reviewdb.server.DisallowedReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.server.OrmException;
@@ -24,13 +26,10 @@
 
 @Singleton
 public class NotesMigrationSchemaFactory implements SchemaFactory<ReviewDb> {
-  private final SchemaFactory<ReviewDb> delegate;
   private final NotesMigration migration;
 
   @Inject
-  NotesMigrationSchemaFactory(
-      @ReviewDbFactory SchemaFactory<ReviewDb> delegate, NotesMigration migration) {
-    this.delegate = delegate;
+  NotesMigrationSchemaFactory(NotesMigration migration) {
     this.migration = migration;
   }
 
@@ -43,7 +42,7 @@
     //    in ReviewDb, it is generally programmer error to read changes from ReviewDb. However,
     //    since ReviewDb is still the primary storage for most or all changes, we still need to
     //    support writing to ReviewDb. This behavior is accomplished by wrapping in a
-    //    DisallowReadFromChangesReviewDbWrapper.
+    //    DisallowedReviewDb.
     //
     //    Some codepaths might need to be able to read from ReviewDb if they really need to,
     //    because they need to operate on the underlying source of truth, for example when reading
@@ -56,22 +55,22 @@
     //    continue to function.
     //
     //    This is accomplished by setting the delegate ReviewDb *underneath*
-    //    DisallowReadFromChanges to be a complete no-op, with NoChangesReviewDbWrapper. With this
-    //    wrapper, all read operations return no results, and write operations silently do nothing.
-    //    This wrapper is not a public class and nobody should ever attempt to unwrap it.
+    //    DisallowReadFromChanges to be a complete no-op, with NoChangesReviewDb. With this
+    //    stub implementation, all read operations return no results, and write operations silently
+    //    do nothing. This implementation is not a public class and callers couldn't do anything
+    //    useful with it even if it were.
 
-    // First create the wrappers which can not be removed by ReviewDbUtil#unwrapDb(ReviewDb).
-    ReviewDb db = delegate.open();
-    if (migration.readChanges() && migration.disableChangeReviewDb()) {
-      // Disable writes to change tables in ReviewDb (ReviewDb access for changes are No-Ops).
-      db = new NoChangesReviewDbWrapper(db);
-    }
+    // First create the underlying stub.
+    checkState(migration.readChanges() && migration.disableChangeReviewDb());
+    // Disable writes to change tables in ReviewDb (ReviewDb access for changes are No-Ops); all
+    // other table accesses throw runtime exceptions.
+    ReviewDb db = new NoChangesReviewDb();
 
     // Second create the wrappers which can be removed by ReviewDbUtil#unwrapDb(ReviewDb).
     if (migration.readChanges()) {
       // If reading changes from NoteDb is configured, changes should not be read from ReviewDb.
       // Make sure that any attempt to read a change from ReviewDb anyway fails with an exception.
-      db = new DisallowReadFromChangesReviewDbWrapper(db);
+      db = new DisallowedReviewDb(db);
     }
     return db;
   }
diff --git a/java/com/google/gerrit/server/schema/ReviewDbSchemaCreator.java b/java/com/google/gerrit/server/schema/ReviewDbSchemaCreator.java
index 3a42f07..2113a36 100644
--- a/java/com/google/gerrit/server/schema/ReviewDbSchemaCreator.java
+++ b/java/com/google/gerrit/server/schema/ReviewDbSchemaCreator.java
@@ -42,7 +42,6 @@
 import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gwtorm.jdbc.JdbcExecutor;
 import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -72,7 +71,6 @@
 
   private final Config config;
   private final MetricMaker metricMaker;
-  private final NotesMigration migration;
   private final AllProjectsName allProjectsName;
 
   @Inject
@@ -88,7 +86,6 @@
       @GerritServerId String serverId,
       @GerritServerConfig Config config,
       MetricMaker metricMaker,
-      NotesMigration migration,
       AllProjectsName apName) {
     this(
         site.site_path,
@@ -102,7 +99,6 @@
         serverId,
         config,
         metricMaker,
-        migration,
         apName);
   }
 
@@ -118,7 +114,6 @@
       String serverId,
       Config config,
       MetricMaker metricMaker,
-      NotesMigration migration,
       AllProjectsName apName) {
     site_path = site;
     this.repoManager = repoManager;
@@ -132,7 +127,6 @@
 
     this.config = config;
     this.allProjectsName = apName;
-    this.migration = migration;
     this.metricMaker = metricMaker;
   }
 
@@ -157,8 +151,6 @@
     Sequences seqs =
         new Sequences(
             config,
-            () -> db,
-            migration,
             repoManager,
             GitReferenceUpdated.DISABLED,
             allProjectsName,
diff --git a/java/com/google/gerrit/server/schema/ReviewDbSchemaVersion.java b/java/com/google/gerrit/server/schema/ReviewDbSchemaVersion.java
index 10a2d39..f911a8f 100644
--- a/java/com/google/gerrit/server/schema/ReviewDbSchemaVersion.java
+++ b/java/com/google/gerrit/server/schema/ReviewDbSchemaVersion.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.schema;
 
+import static com.google.common.base.Preconditions.checkState;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.Lists;
@@ -36,8 +38,17 @@
 /** A version of the database schema. */
 public abstract class ReviewDbSchemaVersion {
   /** The current schema version. */
+  // DO NOT upgrade this version in the master branch. Future versions must all be implemented as
+  // NoteDbSchemaVersions. It may be upgraded on the stable-2.16 branch, in which case this will
+  // need to be updated upon merging. In any case, this number must not exceed the first NoteDb
+  // schema version (180).
   public static final Class<Schema_170> C = Schema_170.class;
 
+  static {
+    checkState(C.equals(Schema_170.class));
+    checkState(guessVersion(C) < 180);
+  }
+
   public static int getBinaryVersion() {
     return guessVersion(C);
   }
diff --git a/java/com/google/gerrit/server/schema/ReviewDbSchemaVersionCheck.java b/java/com/google/gerrit/server/schema/ReviewDbSchemaVersionCheck.java
deleted file mode 100644
index 12abc7e..0000000
--- a/java/com/google/gerrit/server/schema/ReviewDbSchemaVersionCheck.java
+++ /dev/null
@@ -1,95 +0,0 @@
-// Copyright (C) 2009 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.google.gerrit.server.schema;
-
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.ProvisionException;
-
-/** Validates the current schema version. */
-public class ReviewDbSchemaVersionCheck implements LifecycleListener {
-  public static Module module() {
-    return new LifecycleModule() {
-      @Override
-      protected void configure() {
-        listener().to(ReviewDbSchemaVersionCheck.class);
-      }
-    };
-  }
-
-  private final SchemaFactory<ReviewDb> schema;
-  private final SitePaths site;
-
-  @Inject
-  public ReviewDbSchemaVersionCheck(SchemaFactory<ReviewDb> schemaFactory, SitePaths site) {
-    this.schema = schemaFactory;
-    this.site = site;
-  }
-
-  @Override
-  public void start() {
-    try (ReviewDb db = schema.open()) {
-      final CurrentSchemaVersion currentVer = getSchemaVersion(db);
-      final int expectedVer = ReviewDbSchemaVersion.getBinaryVersion();
-
-      if (currentVer == null) {
-        throw new ProvisionException(
-            "Schema not yet initialized."
-                + "  Run init to initialize the schema:\n"
-                + "$ java -jar gerrit.war init -d "
-                + site.site_path.toAbsolutePath());
-      }
-      if (currentVer.versionNbr < expectedVer) {
-        throw new ProvisionException(
-            "Unsupported schema version "
-                + currentVer.versionNbr
-                + "; expected schema version "
-                + expectedVer
-                + ".  Run init to upgrade:\n"
-                + "$ java -jar "
-                + site.gerrit_war.toAbsolutePath()
-                + " init -d "
-                + site.site_path.toAbsolutePath());
-      } else if (currentVer.versionNbr > expectedVer) {
-        throw new ProvisionException(
-            "Unsupported schema version "
-                + currentVer.versionNbr
-                + "; expected schema version "
-                + expectedVer
-                + ". Downgrade is not supported.");
-      }
-    } catch (OrmException e) {
-      throw new ProvisionException("Cannot read schema_version", e);
-    }
-  }
-
-  @Override
-  public void stop() {}
-
-  private CurrentSchemaVersion getSchemaVersion(ReviewDb db) {
-    try {
-      return db.schemaVersion().get(new CurrentSchemaVersion.Key());
-    } catch (OrmException e) {
-      return null;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/schema/Schema_180.java b/java/com/google/gerrit/server/schema/Schema_180.java
new file mode 100644
index 0000000..4d16022
--- /dev/null
+++ b/java/com/google/gerrit/server/schema/Schema_180.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+public class Schema_180 implements NoteDbSchemaVersion {
+  @SuppressWarnings("unused")
+  Schema_180(Arguments args) {
+    // Do nothing.
+  }
+
+  @Override
+  public void upgrade(UpdateUI ui) {
+    // Do nothing; only used to populate the version ref, which is done by the caller.
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/NoteDbSchemaVersionManagerTest.java b/javatests/com/google/gerrit/server/notedb/NoteDbSchemaVersionManagerTest.java
new file mode 100644
index 0000000..c8900309
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/NoteDbSchemaVersionManagerTest.java
@@ -0,0 +1,79 @@
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_VERSION;
+
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+public class NoteDbSchemaVersionManagerTest extends GerritBaseTests {
+  private NoteDbSchemaVersionManager manager;
+  private TestRepository<?> tr;
+
+  @Before
+  public void setUp() throws Exception {
+    AllProjectsName allProjectsName = new AllProjectsName("The-Projects");
+    GitRepositoryManager repoManager = new InMemoryRepositoryManager();
+    tr = new TestRepository<>(repoManager.createRepository(allProjectsName));
+    manager = new NoteDbSchemaVersionManager(allProjectsName, repoManager);
+  }
+
+  @Test
+  public void readMissing() throws Exception {
+    assertThat(manager.read()).isEqualTo(0);
+  }
+
+  @Test
+  public void read() throws Exception {
+    tr.update(REFS_VERSION, tr.blob("123"));
+    assertThat(manager.read()).isEqualTo(123);
+  }
+
+  @Test
+  public void readInvalid() throws Exception {
+    ObjectId blobId = tr.blob(" 1 2 3 ");
+    tr.update(REFS_VERSION, blobId);
+    try {
+      manager.read();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("invalid value in refs/meta/version blob at " + blobId.name());
+    }
+  }
+
+  @Test
+  public void incrementFromMissing() throws Exception {
+    manager.increment(123);
+    assertThat(manager.read()).isEqualTo(124);
+  }
+
+  @Test
+  public void increment() throws Exception {
+    tr.update(REFS_VERSION, tr.blob("123"));
+    manager.increment(123);
+    assertThat(manager.read()).isEqualTo(124);
+  }
+
+  @Test
+  public void incrementWrongOldVersion() throws Exception {
+    tr.update(REFS_VERSION, tr.blob("123"));
+    try {
+      manager.increment(456);
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .isEqualTo("Expected old version 456 for refs/meta/version, found 123");
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
new file mode 100644
index 0000000..9af7b1b
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaUpdaterTest.java
@@ -0,0 +1,287 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.schema.NoteDbSchemaUpdater.requiredUpgrades;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.IntBlob;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NoteDbSchemaVersionManager;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gerrit.testing.GerritBaseTests;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.gerrit.testing.TestUpdateUI;
+import com.google.gwtorm.server.OrmException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class NoteDbSchemaUpdaterTest extends GerritBaseTests {
+  @Test
+  public void requiredUpgradesFromNoVersion() throws Exception {
+    assertThat(requiredUpgrades(0, versions(10))).containsExactly(10).inOrder();
+    assertThat(requiredUpgrades(0, versions(10, 11, 12))).containsExactly(10, 11, 12).inOrder();
+  }
+
+  @Test
+  public void requiredUpgradesFromExistingVersion() throws Exception {
+    ImmutableSortedSet<Integer> versions = versions(10, 11, 12, 13);
+    assertThat(requiredUpgrades(10, versions)).containsExactly(11, 12, 13).inOrder();
+    assertThat(requiredUpgrades(11, versions)).containsExactly(12, 13).inOrder();
+    assertThat(requiredUpgrades(12, versions)).containsExactly(13).inOrder();
+    assertThat(requiredUpgrades(13, versions)).isEmpty();
+  }
+
+  @Test
+  public void downgradeNotSupported() throws Exception {
+    try {
+      requiredUpgrades(14, versions(10, 11, 12, 13));
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e)
+          .hasMessageThat()
+          .contains("Cannot downgrade NoteDb schema from version 14 to 13");
+    }
+  }
+
+  @Test
+  public void skipToFirstVersionNotSupported() throws Exception {
+    ImmutableSortedSet<Integer> versions = versions(10, 11, 12);
+    assertThat(requiredUpgrades(9, versions)).containsExactly(10, 11, 12).inOrder();
+    try {
+      requiredUpgrades(8, versions);
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e).hasMessageThat().contains("Cannot skip NoteDb schema from version 8 to 10");
+    }
+  }
+
+  private static class TestUpdate {
+    protected final Config cfg;
+    protected final AllProjectsName allProjectsName;
+    protected final AllUsersName allUsersName;
+    protected final NoteDbSchemaUpdater updater;
+    protected final GitRepositoryManager repoManager;
+    protected final NoteDbSchemaVersion.Arguments args;
+    private final List<String> messages;
+
+    TestUpdate(Optional<Integer> initialVersion) throws Exception {
+      cfg = new Config();
+      allProjectsName = new AllProjectsName("The-Projects");
+      allUsersName = new AllUsersName("The-Users");
+      repoManager = new InMemoryRepositoryManager();
+      try (Repository repo = repoManager.createRepository(allProjectsName)) {
+        if (initialVersion.isPresent()) {
+          TestRepository<?> tr = new TestRepository<>(repo);
+          tr.update(RefNames.REFS_VERSION, tr.blob(initialVersion.get().toString()));
+        }
+      }
+      repoManager.createRepository(allUsersName).close();
+
+      setUp();
+
+      args = new NoteDbSchemaVersion.Arguments(repoManager, allProjectsName);
+      NoteDbSchemaVersionManager versionManager =
+          new NoteDbSchemaVersionManager(allProjectsName, repoManager);
+      MutableNotesMigration notesMigration = MutableNotesMigration.newDisabled();
+      notesMigration.setFrom(NotesMigrationState.NOTE_DB);
+      updater =
+          new NoteDbSchemaUpdater(
+              cfg,
+              allUsersName,
+              repoManager,
+              notesMigration,
+              versionManager,
+              args,
+              ImmutableSortedMap.of(10, TestSchema_10.class, 11, TestSchema_11.class));
+      messages = new ArrayList<>();
+    }
+
+    protected void setNotesMigrationConfig() {
+      cfg.setString("noteDb", "changes", "write", "true");
+      cfg.setString("noteDb", "changes", "read", "true");
+      cfg.setString("noteDb", "changes", "primaryStorage", "NOTE_DB");
+      cfg.setString("noteDb", "changes", "disableReviewDb", "true");
+    }
+
+    protected void seedGroupSequenceRef() throws Exception {
+      new RepoSequence(
+              repoManager,
+              GitReferenceUpdated.DISABLED,
+              allUsersName,
+              Sequences.NAME_GROUPS,
+              () -> 1,
+              1)
+          .next();
+    }
+
+    protected void setUp() throws Exception {}
+
+    ImmutableList<String> update() throws Exception {
+      updater.update(
+          new TestUpdateUI() {
+            @Override
+            public void message(String m) {
+              messages.add(m);
+            }
+          });
+      return getMessages();
+    }
+
+    ImmutableList<String> getMessages() {
+      return ImmutableList.copyOf(messages);
+    }
+
+    Optional<Integer> readVersion() throws Exception {
+      try (Repository repo = repoManager.openRepository(allProjectsName)) {
+        return IntBlob.parse(repo, RefNames.REFS_VERSION).map(IntBlob::value);
+      }
+    }
+
+    private static class TestSchema_10 implements NoteDbSchemaVersion {
+      @SuppressWarnings("unused")
+      TestSchema_10(Arguments args) {
+        // Do nothing.
+      }
+
+      @Override
+      public void upgrade(UpdateUI ui) {
+        ui.message("body of 10");
+      }
+    }
+
+    private static class TestSchema_11 implements NoteDbSchemaVersion {
+      @SuppressWarnings("unused")
+      TestSchema_11(Arguments args) {
+        // Do nothing.
+      }
+
+      @Override
+      public void upgrade(UpdateUI ui) {
+        ui.message("BODY OF 11");
+      }
+    }
+  }
+
+  @Test
+  public void bootstrapUpdateWith216Prerequisites() throws Exception {
+    TestUpdate u =
+        new TestUpdate(Optional.empty()) {
+          @Override
+          public void setUp() throws Exception {
+            setNotesMigrationConfig();
+            seedGroupSequenceRef();
+          }
+        };
+    assertThat(u.update())
+        .containsExactly(
+            "Migrating data to schema 10 ...",
+            "body of 10",
+            "Migrating data to schema 11 ...",
+            "BODY OF 11")
+        .inOrder();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  @Test
+  public void bootstrapUpdateFailsWithoutNotesMigrationConfig() throws Exception {
+    TestUpdate u =
+        new TestUpdate(Optional.empty()) {
+          @Override
+          public void setUp() throws Exception {
+            seedGroupSequenceRef();
+          }
+        };
+    try {
+      u.update();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e).hasMessageThat().contains("NoteDb change migration was not completed");
+    }
+    assertThat(u.getMessages()).isEmpty();
+    assertThat(u.readVersion()).isEmpty();
+  }
+
+  @Test
+  public void bootstrapUpdateFailsWithoutGroupSequenceRef() throws Exception {
+    TestUpdate u =
+        new TestUpdate(Optional.empty()) {
+          @Override
+          public void setUp() throws Exception {
+            setNotesMigrationConfig();
+          }
+        };
+    try {
+      u.update();
+      assert_().fail("expected OrmException");
+    } catch (OrmException e) {
+      assertThat(e).hasMessageThat().contains("upgrade to 2.16.x first");
+    }
+    assertThat(u.getMessages()).isEmpty();
+    assertThat(u.readVersion()).isEmpty();
+  }
+
+  @Test
+  public void updateTwoVersions() throws Exception {
+    TestUpdate u = new TestUpdate(Optional.of(9));
+    assertThat(u.update())
+        .containsExactly(
+            "Migrating data to schema 10 ...",
+            "body of 10",
+            "Migrating data to schema 11 ...",
+            "BODY OF 11")
+        .inOrder();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  @Test
+  public void updateOneVersion() throws Exception {
+    TestUpdate u = new TestUpdate(Optional.of(10));
+    assertThat(u.update())
+        .containsExactly("Migrating data to schema 11 ...", "BODY OF 11")
+        .inOrder();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  @Test
+  public void updateNoOp() throws Exception {
+    // This test covers the state when running the updater after initializing a new 3.x site, which
+    // seeds the schema version ref with the latest version.
+    TestUpdate u = new TestUpdate(Optional.of(11));
+    assertThat(u.update()).isEmpty();
+    assertThat(u.readVersion()).hasValue(11);
+  }
+
+  private static ImmutableSortedSet<Integer> versions(Integer... versions) {
+    return ImmutableSortedSet.copyOf(versions);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
new file mode 100644
index 0000000..02388ba
--- /dev/null
+++ b/javatests/com/google/gerrit/server/schema/NoteDbSchemaVersionsTest.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2018 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.google.gerrit.server.schema;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.server.schema.NoteDbSchemaVersions.guessVersion;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Streams;
+import com.google.common.reflect.ClassPath;
+import com.google.common.reflect.ClassPath.ClassInfo;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.util.stream.IntStream;
+import org.junit.Test;
+
+public class NoteDbSchemaVersionsTest extends GerritBaseTests {
+  @Test
+  public void testGuessVersion() {
+    assertThat(guessVersion(getClass())).isEmpty();
+    assertThat(guessVersion(Schema_180.class)).hasValue(180);
+  }
+
+  @Test
+  public void contiguousVersions() {
+    ImmutableSortedSet<Integer> keys = NoteDbSchemaVersions.ALL.keySet();
+    ImmutableList<Integer> expected =
+        IntStream.rangeClosed(keys.first(), keys.last()).boxed().collect(toImmutableList());
+    assertThat(keys).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  @Test
+  public void exceedsReviewDbVersion() {
+    assertThat(NoteDbSchemaVersions.ALL.firstKey())
+        // TODO(dborowitz): Replace with hard-coded max number once ReviewDb code is deleted.
+        .isGreaterThan(ReviewDbSchemaVersion.guessVersion(ReviewDbSchemaVersion.C));
+  }
+
+  @Test
+  public void containsAllNoteDbSchemas() throws Exception {
+    int minNoteDbVersion = 180;
+    ImmutableList<Integer> allSchemaVersions =
+        ClassPath.from(getClass().getClassLoader())
+            .getTopLevelClasses(getClass().getPackage().getName())
+            .stream()
+            .map(ClassInfo::load)
+            .map(NoteDbSchemaVersions::guessVersion)
+            .flatMap(Streams::stream)
+            .filter(v -> v >= minNoteDbVersion)
+            .sorted()
+            .collect(toImmutableList());
+    assertThat(NoteDbSchemaVersions.ALL.keySet())
+        .containsExactlyElementsIn(allSchemaVersions)
+        .inOrder();
+  }
+
+  @Test
+  public void schemaConstructors() throws Exception {
+    NoteDbSchemaVersion.Arguments args = new NoteDbSchemaVersion.Arguments(null, null);
+    for (int version : NoteDbSchemaVersions.ALL.keySet()) {
+      NoteDbSchemaVersions.get(NoteDbSchemaVersions.ALL, version, args);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInNoteDbTest.java b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInNoteDbTest.java
deleted file mode 100644
index f17550e..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInNoteDbTest.java
+++ /dev/null
@@ -1,227 +0,0 @@
-// Copyright (C) 2018 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.google.gerrit.server.schema;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
-import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerIdProvider;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.PreparedStatement;
-import java.sql.Statement;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_166_to_167_WithGroupsInNoteDbTest {
-  private static Config createConfig() {
-    Config config = new Config();
-    config.setString(GerritServerIdProvider.SECTION, null, GerritServerIdProvider.KEY, "1234567");
-
-    // Disable groups in ReviewDb. This means the primary storage for groups is NoteDb.
-    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, true);
-
-    return config;
-  }
-
-  @Rule
-  public InMemoryTestEnvironment testEnv =
-      new InMemoryTestEnvironment(Schema_166_to_167_WithGroupsInNoteDbTest::createConfig);
-
-  @Inject private Schema_167 schema167;
-  @Inject private ReviewDb db;
-  @Inject private GitRepositoryManager gitRepoManager;
-  @Inject private AllUsersName allUsersName;
-  @Inject private @ServerInitiated GroupsUpdate groupsUpdate;
-  @Inject private Sequences seq;
-
-  private JdbcSchema jdbcSchema;
-
-  @Before
-  public void initDb() throws Exception {
-    jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
-
-    try (Statement stmt = jdbcSchema.getConnection().createStatement()) {
-      stmt.execute(
-          "CREATE TABLE account_groups ("
-              + " group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " name varchar(255) DEFAULT '' NOT NULL,"
-              + " created_on TIMESTAMP,"
-              + " description CLOB,"
-              + " owner_group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " visible_to_all CHAR(1) DEFAULT 'N' NOT NULL"
-              + ")");
-    }
-  }
-
-  @Test
-  public void migrationIsSkipped() throws Exception {
-    // Create a group in NoteDb (doesn't create the group in ReviewDb since
-    // disableReviewDb == true)
-    InternalGroup internalGroup =
-        groupsUpdate.createGroup(
-            InternalGroupCreation.builder()
-                .setNameKey(new AccountGroup.NameKey("users"))
-                .setGroupUUID(new AccountGroup.UUID("users"))
-                .setId(new AccountGroup.Id(seq.nextGroupId()))
-                .build(),
-            InternalGroupUpdate.builder().setDescription("description").build());
-
-    // Insert the group into ReviewDb
-    AccountGroup group1 =
-        newGroup()
-            .setName(internalGroup.getName())
-            .setGroupUuid(internalGroup.getGroupUUID())
-            .setId(internalGroup.getId())
-            .setCreatedOn(internalGroup.getCreatedOn())
-            .setDescription(internalGroup.getDescription())
-            .setGroupUuid(internalGroup.getGroupUUID())
-            .setVisibleToAll(internalGroup.isVisibleToAll())
-            .build();
-    storeInReviewDb(group1);
-
-    // Update the group description in ReviewDb so that the group state differs between ReviewDb and
-    // NoteDb
-    group1.setDescription("outdated");
-    updateInReviewDb(group1);
-
-    // Create a group that only exists in ReviewDb
-    AccountGroup group2 = newGroup().setName("reviewDbOnlyGroup").build();
-    storeInReviewDb(group2);
-
-    // Remember the SHA1 of the group ref in NoteDb
-    ObjectId groupSha1 = getGroupSha1(group1.getGroupUUID());
-
-    executeSchemaMigration(schema167);
-
-    // Verify the groups in NoteDb: "users" should still exist, "reviewDbOnlyGroup" should not have
-    // been created
-    ImmutableList<GroupReference> groupReferences = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groupReferences.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).contains("users");
-    assertThat(groupNames).doesNotContain("reviewDbOnlyGroup");
-
-    // Verify that the group refs in NoteDb were not touched.
-    assertThat(getGroupSha1(group1.getGroupUUID())).isEqualTo(groupSha1);
-    assertThat(getGroupSha1(group2.getGroupUUID())).isNull();
-  }
-
-  private static TestGroup.Builder newGroup() {
-    return TestGroup.builder();
-  }
-
-  private void storeInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "INSERT INTO account_groups"
-                    + " (group_uuid,"
-                    + " group_id,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all) VALUES (?, ?, ?, ?, ?, ?, ?)")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setInt(2, group.getId().get());
-        stmt.setString(3, group.getName());
-        stmt.setString(4, group.getDescription());
-        stmt.setTimestamp(5, group.getCreatedOn());
-        stmt.setString(6, group.getOwnerGroupUUID().get());
-        stmt.setString(7, group.isVisibleToAll() ? "Y" : "N");
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void updateInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "UPDATE account_groups SET"
-                    + " group_uuid = ?,"
-                    + " name = ?,"
-                    + " description = ?,"
-                    + " created_on = ?,"
-                    + " owner_group_uuid = ?,"
-                    + " visible_to_all = ?"
-                    + " WHERE group_id = ?")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setString(2, group.getName());
-        stmt.setString(3, group.getDescription());
-        stmt.setTimestamp(4, group.getCreatedOn());
-        stmt.setString(5, group.getOwnerGroupUUID().get());
-        stmt.setString(6, group.isVisibleToAll() ? "Y" : "N");
-        stmt.setInt(7, group.getId().get());
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void executeSchemaMigration(ReviewDbSchemaVersion schema) throws Exception {
-    schema.migrateData(db, new TestUpdateUI());
-  }
-
-  private ImmutableList<GroupReference> getAllGroupsFromNoteDb()
-      throws IOException, ConfigInvalidException {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupNameNotes.loadAllGroups(allUsersRepo);
-    }
-  }
-
-  @Nullable
-  private ObjectId getGroupSha1(AccountGroup.UUID groupUuid) throws IOException {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(groupUuid));
-      return ref != null ? ref.getObjectId() : null;
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java b/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
deleted file mode 100644
index 890ae32..0000000
--- a/javatests/com/google/gerrit/server/schema/Schema_166_to_167_WithGroupsInReviewDbTest.java
+++ /dev/null
@@ -1,1125 +0,0 @@
-// Copyright (C) 2018 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.google.gerrit.server.schema;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.extensions.common.testing.CommitInfoSubject.assertThat;
-import static com.google.gerrit.server.notedb.NoteDbTable.GROUPS;
-import static com.google.gerrit.server.notedb.NotesMigration.DISABLE_REVIEW_DB;
-import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
-import static com.google.gerrit.truth.OptionalSubject.assertThat;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.accounts.AccountInput;
-import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
-import com.google.gerrit.extensions.api.groups.GroupInput;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.Type;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.UserMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.account.GroupUUID;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerId;
-import com.google.gerrit.server.config.GerritServerIdProvider;
-import com.google.gerrit.server.git.CommitUtil;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.group.InternalGroup;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.group.db.GroupConfig;
-import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
-import com.google.gerrit.server.group.testing.InternalGroupSubject;
-import com.google.gerrit.server.group.testing.TestGroupBackend;
-import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.InMemoryTestEnvironment;
-import com.google.gerrit.testing.TestTimeUtil;
-import com.google.gerrit.testing.TestTimeUtil.TempClockStep;
-import com.google.gerrit.testing.TestUpdateUI;
-import com.google.gerrit.truth.OptionalSubject;
-import com.google.gwtorm.jdbc.JdbcSchema;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.PreparedStatement;
-import java.sql.ResultSet;
-import java.sql.Statement;
-import java.sql.Timestamp;
-import java.time.LocalDate;
-import java.time.Month;
-import java.time.ZoneOffset;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Date;
-import java.util.List;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Rule;
-import org.junit.Test;
-
-public class Schema_166_to_167_WithGroupsInReviewDbTest {
-  private static Config createConfig() {
-    Config config = new Config();
-    config.setString(GerritServerIdProvider.SECTION, null, GerritServerIdProvider.KEY, "1234567");
-
-    // Enable groups in ReviewDb. This means the primary storage for groups is ReviewDb.
-    config.setBoolean(SECTION_NOTE_DB, GROUPS.key(), DISABLE_REVIEW_DB, false);
-
-    return config;
-  }
-
-  @Rule
-  public InMemoryTestEnvironment testEnv =
-      new InMemoryTestEnvironment(Schema_166_to_167_WithGroupsInReviewDbTest::createConfig);
-
-  @Inject private GerritApi gApi;
-  @Inject private Schema_167 schema167;
-  @Inject private ReviewDb db;
-  @Inject private GitRepositoryManager gitRepoManager;
-  @Inject private AllUsersName allUsersName;
-  @Inject private GroupsConsistencyChecker consistencyChecker;
-  @Inject private IdentifiedUser currentUser;
-  @Inject private @GerritServerId String serverId;
-  @Inject private @GerritPersonIdent PersonIdent serverIdent;
-  @Inject private GroupBundle.Factory groupBundleFactory;
-  @Inject private GroupBackend groupBackend;
-  @Inject private DynamicSet<GroupBackend> backends;
-  @Inject private Sequences seq;
-
-  private JdbcSchema jdbcSchema;
-
-  @Before
-  public void initDb() throws Exception {
-    jdbcSchema = ReviewDbWrapper.unwrapJbdcSchema(db);
-
-    try (Statement stmt = jdbcSchema.getConnection().createStatement()) {
-      stmt.execute(
-          "CREATE TABLE account_groups ("
-              + " group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " name varchar(255) DEFAULT '' NOT NULL,"
-              + " created_on TIMESTAMP,"
-              + " description CLOB,"
-              + " owner_group_uuid varchar(255) DEFAULT '' NOT NULL,"
-              + " visible_to_all CHAR(1) DEFAULT 'N' NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_members ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " account_id INTEGER DEFAULT 0 NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_members_audit ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " account_id INTEGER DEFAULT 0 NOT NULL,"
-              + " added_by INTEGER DEFAULT 0 NOT NULL,"
-              + " added_on TIMESTAMP,"
-              + " removed_by INTEGER,"
-              + " removed_on TIMESTAMP"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_by_id ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " include_uuid VARCHAR(255) DEFAULT '' NOT NULL"
-              + ")");
-
-      stmt.execute(
-          "CREATE TABLE account_group_by_id_aud ("
-              + " group_id INTEGER DEFAULT 0 NOT NULL,"
-              + " include_uuid VARCHAR(255) DEFAULT '' NOT NULL,"
-              + " added_by INTEGER DEFAULT 0 NOT NULL,"
-              + " added_on TIMESTAMP,"
-              + " removed_by INTEGER,"
-              + " removed_on TIMESTAMP"
-              + ")");
-    }
-  }
-
-  @Before
-  public void setTimeForTesting() {
-    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
-  }
-
-  @After
-  public void resetTime() {
-    TestTimeUtil.useSystemTime();
-  }
-
-  @Test
-  public void reviewDbOnlyGroupsAreMigratedToNoteDb() throws Exception {
-    // Create groups only in ReviewDb
-    AccountGroup group1 = newGroup().setName("verifiers").build();
-    AccountGroup group2 = newGroup().setName("contributors").build();
-    storeInReviewDb(group1, group2);
-
-    executeSchemaMigration(schema167, group1, group2);
-
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).containsAllOf("verifiers", "contributors");
-  }
-
-  @Test
-  public void alreadyExistingGroupsAreMigratedToNoteDb() throws Exception {
-    // Create group in NoteDb and ReviewDb
-    GroupInput groupInput = new GroupInput();
-    groupInput.name = "verifiers";
-    groupInput.description = "old";
-    GroupInfo group1 = gApi.groups().create(groupInput).get();
-    storeInReviewDb(group1);
-
-    // Update group only in ReviewDb
-    AccountGroup group1InReviewDb = getFromReviewDb(new AccountGroup.Id(group1.groupId));
-    group1InReviewDb.setDescription("new");
-    updateInReviewDb(group1InReviewDb);
-
-    // Create a second group in NoteDb and ReviewDb
-    GroupInfo group2 = gApi.groups().create("contributors").get();
-    storeInReviewDb(group2);
-
-    executeSchemaMigration(schema167, group1, group2);
-
-    // Verify that both groups are present in NoteDb
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).containsAllOf("verifiers", "contributors");
-
-    // Verify that group1 has the description from ReviewDb
-    Optional<InternalGroup> group1InNoteDb = getGroupFromNoteDb(new AccountGroup.UUID(group1.id));
-    assertThatGroup(group1InNoteDb).value().description().isEqualTo("new");
-  }
-
-  @Test
-  public void adminGroupIsMigratedToNoteDb() throws Exception {
-    // Administrators group is automatically created for all Gerrit servers (NoteDb only).
-    GroupInfo adminGroup = gApi.groups().id("Administrators").get();
-    storeInReviewDb(adminGroup);
-
-    executeSchemaMigration(schema167, adminGroup);
-
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).contains("Administrators");
-  }
-
-  @Test
-  public void nonInteractiveUsersGroupIsMigratedToNoteDb() throws Exception {
-    // 'Non-Interactive Users' group is automatically created for all Gerrit servers (NoteDb only).
-    GroupInfo nonInteractiveUsersGroup = gApi.groups().id("Non-Interactive Users").get();
-    storeInReviewDb(nonInteractiveUsersGroup);
-
-    executeSchemaMigration(schema167, nonInteractiveUsersGroup);
-
-    ImmutableList<GroupReference> groups = getAllGroupsFromNoteDb();
-    ImmutableList<String> groupNames =
-        groups.stream().map(GroupReference::getName).collect(toImmutableList());
-    assertThat(groupNames).contains("Non-Interactive Users");
-  }
-
-  @Test
-  public void groupsAreConsistentAfterMigrationToNoteDb() throws Exception {
-    // Administrators group are automatically created for all Gerrit servers (NoteDb only).
-    GroupInfo adminGroup = gApi.groups().id("Administrators").get();
-    GroupInfo nonInteractiveUsersGroup = gApi.groups().id("Non-Interactive Users").get();
-    storeInReviewDb(adminGroup, nonInteractiveUsersGroup);
-
-    AccountGroup group1 = newGroup().setName("verifiers").build();
-    AccountGroup group2 = newGroup().setName("contributors").build();
-    storeInReviewDb(group1, group2);
-
-    executeSchemaMigration(schema167, group1, group2);
-
-    List<ConsistencyCheckInfo.ConsistencyProblemInfo> consistencyProblems =
-        consistencyChecker.check();
-    assertThat(consistencyProblems).isEmpty();
-  }
-
-  @Test
-  public void nameIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setName("verifiers").build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().name().isEqualTo("verifiers");
-  }
-
-  @Test
-  public void emptyNameIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setName("").build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().name().isEqualTo("");
-  }
-
-  @Test
-  public void uuidIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup.UUID groupUuid = new AccountGroup.UUID("ABCDEF");
-    AccountGroup group = newGroup().setGroupUuid(groupUuid).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(groupUuid);
-    assertThatGroup(groupInNoteDb).value().groupUuid().isEqualTo(groupUuid);
-  }
-
-  @Test
-  public void idIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup.Id id = new AccountGroup.Id(12345);
-    AccountGroup group = newGroup().setId(id).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().id().isEqualTo(id);
-  }
-
-  @Test
-  public void createdOnIsKeptDuringMigrationToNoteDb() throws Exception {
-    Timestamp createdOn =
-        Timestamp.from(
-            LocalDate.of(2018, Month.FEBRUARY, 20)
-                .atTime(18, 2, 56)
-                .atZone(ZoneOffset.UTC)
-                .toInstant());
-    AccountGroup group = newGroup().setCreatedOn(createdOn).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().createdOn().isEqualTo(createdOn);
-  }
-
-  @Test
-  public void ownerUuidIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID("UVWXYZ");
-    AccountGroup group = newGroup().setOwnerGroupUuid(ownerGroupUuid).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().ownerGroupUuid().isEqualTo(ownerGroupUuid);
-  }
-
-  @Test
-  public void descriptionIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setDescription("A test group").build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().description().isEqualTo("A test group");
-  }
-
-  @Test
-  public void absentDescriptionIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().description().isNull();
-  }
-
-  @Test
-  public void visibleToAllIsKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().setVisibleToAll(true).build();
-    storeInReviewDb(group);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().visibleToAll().isTrue();
-  }
-
-  @Test
-  public void membersAreKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().build();
-    storeInReviewDb(group);
-    Account.Id member1 = new Account.Id(23456);
-    Account.Id member2 = new Account.Id(93483);
-    addMembersInReviewDb(group.getId(), member1, member2);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().members().containsExactly(member1, member2);
-  }
-
-  @Test
-  public void subgroupsAreKeptDuringMigrationToNoteDb() throws Exception {
-    AccountGroup group = newGroup().build();
-    storeInReviewDb(group);
-    AccountGroup.UUID subgroup1 = new AccountGroup.UUID("FGHIKL");
-    AccountGroup.UUID subgroup2 = new AccountGroup.UUID("MNOPQR");
-    addSubgroupsInReviewDb(group.getId(), subgroup1, subgroup2);
-
-    executeSchemaMigration(schema167, group);
-
-    Optional<InternalGroup> groupInNoteDb = getGroupFromNoteDb(group.getGroupUUID());
-    assertThatGroup(groupInNoteDb).value().subgroups().containsExactly(subgroup1, subgroup2);
-  }
-
-  @Test
-  public void logFormatWithAccountsAndGerritGroups() throws Exception {
-    AccountInfo user1 = createAccount("user1");
-    AccountInfo user2 = createAccount("user2");
-
-    AccountGroup group1 = createInReviewDb("group1");
-    AccountGroup group2 = createInReviewDb("group2");
-    AccountGroup group3 = createInReviewDb("group3");
-
-    // Add some accounts
-    try (TempClockStep step = TestTimeUtil.freezeClock()) {
-      addMembersInReviewDb(
-          group1.getId(), new Account.Id(user1._accountId), new Account.Id(user2._accountId));
-    }
-    TimeUtil.nowTs();
-
-    // Add some Gerrit groups
-    try (TempClockStep step = TestTimeUtil.freezeClock()) {
-      addSubgroupsInReviewDb(group1.getId(), group2.getGroupUUID(), group3.getGroupUUID());
-    }
-
-    executeSchemaMigration(schema167, group1, group2, group3);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group1.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group1);
-    assertThat(log).hasSize(4);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added members
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + ("Add: user1 <" + user1._accountId + "@" + serverId + ">\n")
-                + ("Add: user2 <" + user2._accountId + "@" + serverId + ">"));
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify commit that added Gerrit groups
-    assertThat(log.get(3))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + ("Add-group: " + group2.getName() + " <" + group2.getGroupUUID().get() + ">\n")
-                + ("Add-group: " + group3.getName() + " <" + group3.getGroupUUID().get() + ">"));
-    assertThat(log.get(3)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(3)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(3)).committer().hasSameDateAs(log.get(3).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group1.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(5);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(4), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertMemberAuditEvents(
-        auditEvents.get(3),
-        auditEvents.get(2),
-        Type.ADD_USER,
-        currentUser.getAccountId(),
-        user1,
-        user2);
-    assertSubgroupAuditEvents(
-        auditEvents.get(1),
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        toGroupInfo(group2),
-        toGroupInfo(group3));
-  }
-
-  @Test
-  public void logFormatWithSystemGroups() throws Exception {
-    AccountGroup group = createInReviewDb("group");
-
-    try (TempClockStep step = TestTimeUtil.freezeClock()) {
-      addSubgroupsInReviewDb(
-          group.getId(), SystemGroupBackend.ANONYMOUS_USERS, SystemGroupBackend.REGISTERED_USERS);
-    }
-
-    executeSchemaMigration(schema167, group);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group);
-    assertThat(log).hasSize(3);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added system groups
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + "Add-group: Anonymous Users <global:Anonymous-Users>\n"
-                + "Add-group: Registered Users <global:Registered-Users>");
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(3);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(2), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertSubgroupAuditEvents(
-        auditEvents.get(1),
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        groupInfoForExternalGroup(SystemGroupBackend.ANONYMOUS_USERS),
-        groupInfoForExternalGroup(SystemGroupBackend.REGISTERED_USERS));
-  }
-
-  @Test
-  public void logFormatWithExternalGroup() throws Exception {
-    AccountGroup group = createInReviewDb("group");
-
-    TestGroupBackend testGroupBackend = new TestGroupBackend();
-    backends.add("gerrit", testGroupBackend);
-    AccountGroup.UUID subgroupUuid = testGroupBackend.create("test").getGroupUUID();
-    assertThat(groupBackend.handles(subgroupUuid)).isTrue();
-    addSubgroupsInReviewDb(group.getId(), subgroupUuid);
-
-    executeSchemaMigration(schema167, group);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group);
-    assertThat(log).hasSize(3);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added system groups
-    // Note: The schema migration can only resolve names of Gerrit groups, not of external groups
-    // and system groups, hence the UUID shows up in commit messages where we would otherwise
-    // expect the group name.
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo(
-            "Update group\n"
-                + "\n"
-                + "Add-group: "
-                + subgroupUuid.get()
-                + " <"
-                + subgroupUuid.get()
-                + ">");
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(2);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(1), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertSubgroupAuditEvent(
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        groupInfoForExternalGroup(subgroupUuid));
-  }
-
-  @Test
-  public void logFormatWithNonExistingExternalGroup() throws Exception {
-    AccountGroup group = createInReviewDb("group");
-
-    AccountGroup.UUID subgroupUuid = new AccountGroup.UUID("notExisting:foo");
-
-    assertThat(groupBackend.handles(subgroupUuid)).isFalse();
-    addSubgroupsInReviewDb(group.getId(), subgroupUuid);
-
-    executeSchemaMigration(schema167, group);
-
-    GroupBundle noteDbBundle = readGroupBundleFromNoteDb(group.getGroupUUID());
-
-    ImmutableList<CommitInfo> log = log(group);
-    assertThat(log).hasSize(3);
-
-    // Verify commit that created the group
-    assertThat(log.get(0)).message().isEqualTo("Create group");
-    assertThat(log.get(0)).author().name().isEqualTo(serverIdent.getName());
-    assertThat(log.get(0)).author().email().isEqualTo(serverIdent.getEmailAddress());
-    assertThat(log.get(0)).author().date().isEqualTo(noteDbBundle.group().getCreatedOn());
-    assertThat(log.get(0)).author().tz().isEqualTo(serverIdent.getTimeZoneOffset());
-    assertThat(log.get(0)).committer().isEqualTo(log.get(0).author);
-
-    // Verify commit that the group creator as member
-    assertThat(log.get(1))
-        .message()
-        .isEqualTo(
-            "Update group\n\nAdd: "
-                + currentUser.getName()
-                + " <"
-                + currentUser.getAccountId()
-                + "@"
-                + serverId
-                + ">");
-    assertThat(log.get(1)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(1)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(1)).committer().hasSameDateAs(log.get(1).author);
-
-    // Verify commit that added system groups
-    // Note: The schema migration can only resolve names of Gerrit groups, not of external groups
-    // and system groups, hence the UUID shows up in commit messages where we would otherwise
-    // expect the group name.
-    assertThat(log.get(2))
-        .message()
-        .isEqualTo("Update group\n" + "\n" + "Add-group: notExisting:foo <notExisting:foo>");
-    assertThat(log.get(2)).author().name().isEqualTo(currentUser.getName());
-    assertThat(log.get(2)).author().email().isEqualTo(currentUser.getAccountId() + "@" + serverId);
-    assertThat(log.get(2)).committer().hasSameDateAs(log.get(2).author);
-
-    // Verify that audit log is correctly read by Gerrit
-    List<? extends GroupAuditEventInfo> auditEvents =
-        gApi.groups().id(group.getGroupUUID().get()).auditLog();
-    assertThat(auditEvents).hasSize(2);
-    AccountInfo currentUserInfo = gApi.accounts().id(currentUser.getAccountId().get()).get();
-    assertMemberAuditEvent(
-        auditEvents.get(1), Type.ADD_USER, currentUser.getAccountId(), currentUserInfo);
-    assertSubgroupAuditEvent(
-        auditEvents.get(0),
-        Type.ADD_GROUP,
-        currentUser.getAccountId(),
-        groupInfoForExternalGroup(subgroupUuid));
-  }
-
-  private static TestGroup.Builder newGroup() {
-    return TestGroup.builder();
-  }
-
-  private AccountGroup createInReviewDb(String groupName) throws Exception {
-    AccountGroup group =
-        new AccountGroup(
-            new AccountGroup.NameKey(groupName),
-            new AccountGroup.Id(seq.nextGroupId()),
-            GroupUUID.make(groupName, serverIdent),
-            TimeUtil.nowTs());
-    storeInReviewDb(group);
-    addMembersInReviewDb(group.getId(), currentUser.getAccountId());
-    return group;
-  }
-
-  private void storeInReviewDb(GroupInfo... groups) throws Exception {
-    storeInReviewDb(
-        Arrays.stream(groups)
-            .map(Schema_166_to_167_WithGroupsInReviewDbTest::toAccountGroup)
-            .toArray(AccountGroup[]::new));
-  }
-
-  private void storeInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "INSERT INTO account_groups"
-                    + " (group_uuid,"
-                    + " group_id,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all) VALUES (?, ?, ?, ?, ?, ?, ?)")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setInt(2, group.getId().get());
-        stmt.setString(3, group.getName());
-        stmt.setString(4, group.getDescription());
-        stmt.setTimestamp(5, group.getCreatedOn());
-        stmt.setString(6, group.getOwnerGroupUUID().get());
-        stmt.setString(7, group.isVisibleToAll() ? "Y" : "N");
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private void updateInReviewDb(AccountGroup... groups) throws Exception {
-    try (PreparedStatement stmt =
-        jdbcSchema
-            .getConnection()
-            .prepareStatement(
-                "UPDATE account_groups SET"
-                    + " group_uuid = ?,"
-                    + " name = ?,"
-                    + " description = ?,"
-                    + " created_on = ?,"
-                    + " owner_group_uuid = ?,"
-                    + " visible_to_all = ?"
-                    + " WHERE group_id = ?")) {
-      for (AccountGroup group : groups) {
-        stmt.setString(1, group.getGroupUUID().get());
-        stmt.setString(2, group.getName());
-        stmt.setString(3, group.getDescription());
-        stmt.setTimestamp(4, group.getCreatedOn());
-        stmt.setString(5, group.getOwnerGroupUUID().get());
-        stmt.setString(6, group.isVisibleToAll() ? "Y" : "N");
-        stmt.setInt(7, group.getId().get());
-        stmt.addBatch();
-      }
-      stmt.executeBatch();
-    }
-  }
-
-  private AccountGroup getFromReviewDb(AccountGroup.Id groupId) throws Exception {
-    try (Statement stmt = jdbcSchema.getConnection().createStatement();
-        ResultSet rs =
-            stmt.executeQuery(
-                "SELECT group_uuid,"
-                    + " name,"
-                    + " description,"
-                    + " created_on,"
-                    + " owner_group_uuid,"
-                    + " visible_to_all"
-                    + " FROM account_groups"
-                    + " WHERE group_id = "
-                    + groupId.get())) {
-      if (!rs.next()) {
-        throw new OrmException(String.format("Group %s not found", groupId.get()));
-      }
-
-      AccountGroup.UUID groupUuid = new AccountGroup.UUID(rs.getString(1));
-      AccountGroup.NameKey groupName = new AccountGroup.NameKey(rs.getString(2));
-      String description = rs.getString(3);
-      Timestamp createdOn = rs.getTimestamp(4);
-      AccountGroup.UUID ownerGroupUuid = new AccountGroup.UUID(rs.getString(5));
-      boolean visibleToAll = "Y".equals(rs.getString(6));
-
-      AccountGroup group = new AccountGroup(groupName, groupId, groupUuid, createdOn);
-      group.setDescription(description);
-      group.setOwnerGroupUUID(ownerGroupUuid);
-      group.setVisibleToAll(visibleToAll);
-
-      if (rs.next()) {
-        throw new OrmException(String.format("Group ID %s is ambiguous", groupId.get()));
-      }
-
-      return group;
-    }
-  }
-
-  private void addMembersInReviewDb(AccountGroup.Id groupId, Account.Id... memberIds)
-      throws Exception {
-    try (PreparedStatement addMemberStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_members"
-                        + " (group_id,"
-                        + " account_id) VALUES ("
-                        + groupId.get()
-                        + ", ?)");
-        PreparedStatement addMemberAuditStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_members_audit"
-                        + " (group_id,"
-                        + " account_id,"
-                        + " added_by,"
-                        + " added_on) VALUES ("
-                        + groupId.get()
-                        + ", ?, "
-                        + currentUser.getAccountId().get()
-                        + ", ?)")) {
-      Timestamp addedOn = TimeUtil.nowTs();
-      for (Account.Id memberId : memberIds) {
-        addMemberStmt.setInt(1, memberId.get());
-        addMemberStmt.addBatch();
-
-        addMemberAuditStmt.setInt(1, memberId.get());
-        addMemberAuditStmt.setTimestamp(2, addedOn);
-        addMemberAuditStmt.addBatch();
-      }
-      addMemberStmt.executeBatch();
-      addMemberAuditStmt.executeBatch();
-    }
-  }
-
-  private void addSubgroupsInReviewDb(AccountGroup.Id groupId, AccountGroup.UUID... subgroupUuids)
-      throws Exception {
-    try (PreparedStatement addSubGroupStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_by_id"
-                        + " (group_id,"
-                        + " include_uuid) VALUES ("
-                        + groupId.get()
-                        + ", ?)");
-        PreparedStatement addSubGroupAuditStmt =
-            jdbcSchema
-                .getConnection()
-                .prepareStatement(
-                    "INSERT INTO account_group_by_id_aud"
-                        + " (group_id,"
-                        + " include_uuid,"
-                        + " added_by,"
-                        + " added_on) VALUES ("
-                        + groupId.get()
-                        + ", ?, "
-                        + currentUser.getAccountId().get()
-                        + ", ?)")) {
-      Timestamp addedOn = TimeUtil.nowTs();
-      for (AccountGroup.UUID subgroupUuid : subgroupUuids) {
-        addSubGroupStmt.setString(1, subgroupUuid.get());
-        addSubGroupStmt.addBatch();
-
-        addSubGroupAuditStmt.setString(1, subgroupUuid.get());
-        addSubGroupAuditStmt.setTimestamp(2, addedOn);
-        addSubGroupAuditStmt.addBatch();
-      }
-      addSubGroupStmt.executeBatch();
-      addSubGroupAuditStmt.executeBatch();
-    }
-  }
-
-  private AccountInfo createAccount(String name) throws RestApiException {
-    AccountInput accountInput = new AccountInput();
-    accountInput.username = name;
-    accountInput.name = name;
-    return gApi.accounts().create(accountInput).get();
-  }
-
-  private GroupBundle readGroupBundleFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return groupBundleFactory.fromNoteDb(allUsersName, allUsersRepo, groupUuid);
-    }
-  }
-
-  private void executeSchemaMigration(ReviewDbSchemaVersion schema, AccountGroup... groupsToVerify)
-      throws Exception {
-    executeSchemaMigration(
-        schema,
-        Arrays.stream(groupsToVerify)
-            .map(AccountGroup::getGroupUUID)
-            .toArray(AccountGroup.UUID[]::new));
-  }
-
-  private void executeSchemaMigration(ReviewDbSchemaVersion schema, GroupInfo... groupsToVerify)
-      throws Exception {
-    executeSchemaMigration(
-        schema,
-        Arrays.stream(groupsToVerify)
-            .map(i -> new AccountGroup.UUID(i.id))
-            .toArray(AccountGroup.UUID[]::new));
-  }
-
-  private void executeSchemaMigration(
-      ReviewDbSchemaVersion schema, AccountGroup.UUID... groupsToVerify) throws Exception {
-    List<GroupBundle> reviewDbBundles = new ArrayList<>();
-    for (AccountGroup.UUID groupUuid : groupsToVerify) {
-      reviewDbBundles.add(GroupBundle.Factory.fromReviewDb(db, groupUuid));
-    }
-
-    schema.migrateData(db, new TestUpdateUI());
-
-    for (GroupBundle reviewDbBundle : reviewDbBundles) {
-      assertMigratedCleanly(readGroupBundleFromNoteDb(reviewDbBundle.uuid()), reviewDbBundle);
-    }
-  }
-
-  private void assertMigratedCleanly(GroupBundle noteDbBundle, GroupBundle expectedReviewDbBundle) {
-    assertThat(GroupBundle.compareWithAudits(expectedReviewDbBundle, noteDbBundle)).isEmpty();
-  }
-
-  private ImmutableList<CommitInfo> log(AccountGroup group) throws Exception {
-    ImmutableList.Builder<CommitInfo> result = ImmutableList.builder();
-    List<Date> commitDates = new ArrayList<>();
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(allUsersRepo)) {
-      Ref ref = allUsersRepo.exactRef(RefNames.refsGroups(group.getGroupUUID()));
-      if (ref != null) {
-        rw.sort(RevSort.REVERSE);
-        rw.setRetainBody(true);
-        rw.markStart(rw.parseCommit(ref.getObjectId()));
-        for (RevCommit c : rw) {
-          result.add(CommitUtil.toCommitInfo(c));
-          commitDates.add(c.getCommitterIdent().getWhen());
-        }
-      }
-    }
-    assertThat(commitDates).named("commit timestamps for %s", result).isOrdered();
-    return result.build();
-  }
-
-  private ImmutableList<GroupReference> getAllGroupsFromNoteDb()
-      throws IOException, ConfigInvalidException {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupNameNotes.loadAllGroups(allUsersRepo);
-    }
-  }
-
-  private Optional<InternalGroup> getGroupFromNoteDb(AccountGroup.UUID groupUuid) throws Exception {
-    try (Repository allUsersRepo = gitRepoManager.openRepository(allUsersName)) {
-      return GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid).getLoadedGroup();
-    }
-  }
-
-  private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
-      Optional<InternalGroup> group) {
-    return assertThat(group, InternalGroupSubject::assertThat).named("group");
-  }
-
-  private void assertMemberAuditEvent(
-      GroupAuditEventInfo info,
-      Type expectedType,
-      Account.Id expectedUser,
-      AccountInfo expectedMember) {
-    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
-    assertThat(info.type).isEqualTo(expectedType);
-    assertThat(info).isInstanceOf(UserMemberAuditEventInfo.class);
-    assertAccount(((UserMemberAuditEventInfo) info).member, expectedMember);
-  }
-
-  private void assertMemberAuditEvents(
-      GroupAuditEventInfo info1,
-      GroupAuditEventInfo info2,
-      Type expectedType,
-      Account.Id expectedUser,
-      AccountInfo expectedMember1,
-      AccountInfo expectedMember2) {
-    assertThat(info1).isInstanceOf(UserMemberAuditEventInfo.class);
-    assertThat(info2).isInstanceOf(UserMemberAuditEventInfo.class);
-
-    UserMemberAuditEventInfo event1 = (UserMemberAuditEventInfo) info1;
-    UserMemberAuditEventInfo event2 = (UserMemberAuditEventInfo) info2;
-
-    assertThat(event1.member._accountId)
-        .isAnyOf(expectedMember1._accountId, expectedMember2._accountId);
-    assertThat(event2.member._accountId)
-        .isAnyOf(expectedMember1._accountId, expectedMember2._accountId);
-    assertThat(event1.member._accountId).isNotEqualTo(event2.member._accountId);
-
-    if (event1.member._accountId == expectedMember1._accountId) {
-      assertMemberAuditEvent(info1, expectedType, expectedUser, expectedMember1);
-      assertMemberAuditEvent(info2, expectedType, expectedUser, expectedMember2);
-    } else {
-      assertMemberAuditEvent(info1, expectedType, expectedUser, expectedMember2);
-      assertMemberAuditEvent(info2, expectedType, expectedUser, expectedMember1);
-    }
-  }
-
-  private void assertSubgroupAuditEvent(
-      GroupAuditEventInfo info,
-      Type expectedType,
-      Account.Id expectedUser,
-      GroupInfo expectedSubGroup) {
-    assertThat(info.user._accountId).isEqualTo(expectedUser.get());
-    assertThat(info.type).isEqualTo(expectedType);
-    assertThat(info).isInstanceOf(GroupMemberAuditEventInfo.class);
-    assertGroup(((GroupMemberAuditEventInfo) info).member, expectedSubGroup);
-  }
-
-  private void assertSubgroupAuditEvents(
-      GroupAuditEventInfo info1,
-      GroupAuditEventInfo info2,
-      Type expectedType,
-      Account.Id expectedUser,
-      GroupInfo expectedSubGroup1,
-      GroupInfo expectedSubGroup2) {
-    assertThat(info1).isInstanceOf(GroupMemberAuditEventInfo.class);
-    assertThat(info2).isInstanceOf(GroupMemberAuditEventInfo.class);
-
-    GroupMemberAuditEventInfo event1 = (GroupMemberAuditEventInfo) info1;
-    GroupMemberAuditEventInfo event2 = (GroupMemberAuditEventInfo) info2;
-
-    assertThat(event1.member.id).isAnyOf(expectedSubGroup1.id, expectedSubGroup2.id);
-    assertThat(event2.member.id).isAnyOf(expectedSubGroup1.id, expectedSubGroup2.id);
-    assertThat(event1.member.id).isNotEqualTo(event2.member.id);
-
-    if (event1.member.id.equals(expectedSubGroup1.id)) {
-      assertSubgroupAuditEvent(info1, expectedType, expectedUser, expectedSubGroup1);
-      assertSubgroupAuditEvent(info2, expectedType, expectedUser, expectedSubGroup2);
-    } else {
-      assertSubgroupAuditEvent(info1, expectedType, expectedUser, expectedSubGroup2);
-      assertSubgroupAuditEvent(info2, expectedType, expectedUser, expectedSubGroup1);
-    }
-  }
-
-  private void assertAccount(AccountInfo actual, AccountInfo expected) {
-    assertThat(actual._accountId).isEqualTo(expected._accountId);
-    assertThat(actual.name).isEqualTo(expected.name);
-    assertThat(actual.email).isEqualTo(expected.email);
-    assertThat(actual.username).isEqualTo(expected.username);
-  }
-
-  private void assertGroup(GroupInfo actual, GroupInfo expected) {
-    assertThat(actual.id).isEqualTo(expected.id);
-    assertThat(actual.name).isEqualTo(expected.name);
-    assertThat(actual.groupId).isEqualTo(expected.groupId);
-  }
-
-  private GroupInfo groupInfoForExternalGroup(AccountGroup.UUID groupUuid) {
-    GroupInfo groupInfo = new GroupInfo();
-    groupInfo.id = IdString.fromDecoded(groupUuid.get()).encoded();
-
-    if (groupBackend.handles(groupUuid)) {
-      groupInfo.name = groupBackend.get(groupUuid).getName();
-    }
-
-    return groupInfo;
-  }
-
-  private static AccountGroup toAccountGroup(GroupInfo info) {
-    AccountGroup group =
-        new AccountGroup(
-            new AccountGroup.NameKey(info.name),
-            new AccountGroup.Id(info.groupId),
-            new AccountGroup.UUID(info.id),
-            info.createdOn);
-    group.setDescription(info.description);
-    if (info.ownerId != null) {
-      group.setOwnerGroupUUID(new AccountGroup.UUID(info.ownerId));
-    }
-    group.setVisibleToAll(
-        info.options != null && info.options.visibleToAll != null && info.options.visibleToAll);
-    return group;
-  }
-
-  private static GroupInfo toGroupInfo(AccountGroup group) {
-    GroupInfo groupInfo = new GroupInfo();
-    groupInfo.id = group.getGroupUUID().get();
-    groupInfo.groupId = group.getId().get();
-    groupInfo.name = group.getName();
-    groupInfo.createdOn = group.getCreatedOn();
-    groupInfo.description = group.getDescription();
-    groupInfo.owner = group.getOwnerGroupUUID().get();
-    groupInfo.options = new GroupOptionsInfo();
-    groupInfo.options.visibleToAll = group.isVisibleToAll() ? true : null;
-    return groupInfo;
-  }
-}