Merge "ChangeData: Remove unused setRefStates method"
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index ca13db9..d1826bc 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -443,9 +443,6 @@
   /** Globally assigned unique identifier of the change */
   protected Key changeKey;
 
-  /** optimistic locking */
-  protected int rowVersion;
-
   /** When this change was first introduced into the database. */
   protected Timestamp createdOn;
 
@@ -526,7 +523,6 @@
     assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
-    rowVersion = other.rowVersion;
     createdOn = other.createdOn;
     lastUpdatedOn = other.lastUpdatedOn;
     owner = other.owner;
@@ -587,10 +583,6 @@
     lastUpdatedOn = now;
   }
 
-  public int getRowVersion() {
-    return rowVersion;
-  }
-
   public Account.Id getOwner() {
     return owner;
   }
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 25e68f9..689b4aa 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -43,7 +43,6 @@
     Entities.Change.Builder builder =
         Entities.Change.newBuilder()
             .setChangeId(changeIdConverter.toProto(change.getId()))
-            .setRowVersion(change.getRowVersion())
             .setChangeKey(changeKeyConverter.toProto(change.getKey()))
             .setCreatedOn(change.getCreatedOn().getTime())
             .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 5cc8e3c..b727e96 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -36,6 +36,8 @@
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.account.AccountIndex;
@@ -50,6 +52,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.eclipse.jgit.lib.Config;
 
 /**
  * Fake secondary index implementation for usage in tests. All values are kept in-memory.
@@ -179,14 +182,17 @@
   public static class FakeChangeIndex
       extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex {
     private final ChangeData.Factory changeDataFactory;
+    private final boolean skipMergable;
 
     @Inject
     FakeChangeIndex(
         SitePaths sitePaths,
         ChangeData.Factory changeDataFactory,
-        @Assisted Schema<ChangeData> schema) {
+        @Assisted Schema<ChangeData> schema,
+        @GerritServerConfig Config cfg) {
       super(schema, sitePaths, "changes");
       this.changeDataFactory = changeDataFactory;
+      this.skipMergable = !MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
     }
 
     @Override
@@ -208,6 +214,9 @@
     protected Map<String, Object> docFor(ChangeData value) {
       ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
       for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+        if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+          continue;
+        }
         Object docifiedValue = field.get(value);
         if (docifiedValue != null) {
           doc.put(field.getName(), field.get(value));
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index 50a2f69..435a524 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
@@ -97,12 +98,17 @@
     protected final ExternalIdCache externalIdCache;
     protected final MetricMaker metricMaker;
     protected final AllUsersName allUsersName;
+    protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
 
     protected ExternalIdNotesLoader(
-        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
+        ExternalIdCache externalIdCache,
+        MetricMaker metricMaker,
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
       this.externalIdCache = externalIdCache;
       this.metricMaker = metricMaker;
       this.allUsersName = allUsersName;
+      this.upsertPreprocessors = upsertPreprocessors;
     }
 
     /**
@@ -192,21 +198,24 @@
         ExternalIdCache externalIdCache,
         Provider<AccountIndexer> accountIndexer,
         MetricMaker metricMaker,
-        AllUsersName allUsersName) {
-      super(externalIdCache, metricMaker, allUsersName);
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
+      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors);
       this.accountIndexer = accountIndexer;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .load(rev);
     }
 
     @Override
@@ -220,20 +229,27 @@
 
     @Inject
     FactoryNoReindex(
-        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
-      super(externalIdCache, metricMaker, allUsersName);
+        ExternalIdCache externalIdCache,
+        MetricMaker metricMaker,
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
+      super(externalIdCache, metricMaker, allUsersName, upsertPreprocessors);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load();
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .setNoReindex()
+          .load();
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
-      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo).setNoReindex().load(rev);
+      return new ExternalIdNotes(metricMaker, allUsersName, allUsersRepo, upsertPreprocessors)
+          .setNoReindex()
+          .load(rev);
     }
 
     @Override
@@ -255,7 +271,8 @@
   public static ExternalIdNotes loadReadOnly(
       AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
+    return new ExternalIdNotes(
+            new DisabledMetricMaker(), allUsersName, allUsersRepo, DynamicMap.emptyMap())
         .setReadOnly()
         .setNoCacheUpdate()
         .setNoReindex()
@@ -275,7 +292,8 @@
   public static ExternalIdNotes loadNoCacheUpdate(
       AllUsersName allUsersName, Repository allUsersRepo)
       throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(new DisabledMetricMaker(), allUsersName, allUsersRepo)
+    return new ExternalIdNotes(
+            new DisabledMetricMaker(), allUsersName, allUsersRepo, DynamicMap.emptyMap())
         .setNoCacheUpdate()
         .setNoReindex()
         .load();
@@ -284,6 +302,7 @@
   private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
+  private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
   private final CallerFinder callerFinder;
 
   private NoteMap noteMap;
@@ -312,13 +331,17 @@
   private boolean noReindex = false;
 
   private ExternalIdNotes(
-      MetricMaker metricMaker, AllUsersName allUsersName, Repository allUsersRepo) {
+      MetricMaker metricMaker,
+      AllUsersName allUsersName,
+      Repository allUsersRepo,
+      DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
     this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
     this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
+    this.upsertPreprocessors = upsertPreprocessors;
     this.callerFinder =
         CallerFinder.builder()
             // 1. callers that come through ExternalIds
@@ -490,6 +513,7 @@
         (rw, n) -> {
           for (ExternalId extId : extIds) {
             ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             newExtIds.add(insertedExtId);
           }
         });
@@ -519,6 +543,7 @@
         (rw, n) -> {
           for (ExternalId extId : extIds) {
             ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(updatedExtId);
             updatedExtIds.add(updatedExtId);
           }
         });
@@ -634,6 +659,7 @@
 
           for (ExternalId extId : toAdd) {
             ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -667,6 +693,7 @@
 
           for (ExternalId extId : toAdd) {
             ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
         });
@@ -809,9 +836,9 @@
   }
 
   /**
-   * Insert or updates an new external ID and sets it in the note map.
+   * Inserts or updates a new external ID and sets it in the note map.
    *
-   * <p>If the external ID already exists it is overwritten.
+   * <p>If the external ID already exists, it is overwritten.
    */
   private static ExternalId upsert(
       RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
@@ -867,6 +894,7 @@
    * @return the external ID that was removed, {@code null} if no external ID with the specified key
    *     exists
    */
+  @Nullable
   private static ExternalId remove(
       RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
@@ -917,6 +945,10 @@
     checkState(noteMap != null, "External IDs not loaded yet");
   }
 
+  private void preprocessUpsert(ExternalId extId) {
+    upsertPreprocessors.forEach(p -> p.get().upsert(extId));
+  }
+
   @FunctionalInterface
   private interface NoteMapUpdate {
     void execute(RevWalk rw, NoteMap noteMap)
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
new file mode 100644
index 0000000..c0697db
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * This optional preprocessor is called in {@link ExternalIdNotes} before an update is committed.
+ */
+@ExtensionPoint
+public interface ExternalIdUpsertPreprocessor {
+  /**
+   * Called when inserting or updating an external ID. {@link ExternalId#blobId()} is set. The
+   * upsert can be blocked by throwing {@link com.google.gerrit.exceptions.StorageException}, e.g.
+   * when a precondition or preparatory work fails.
+   */
+  void upsert(ExternalId extId);
+}
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 27b71d6..0d0df0d 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -167,7 +167,6 @@
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
         .putLong(getChange().getLastUpdatedOn().getTime())
-        .putInt(getChange().getRowVersion())
         .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 2d49884..201f122 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -100,6 +100,7 @@
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
 import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
@@ -478,5 +479,6 @@
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
+    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
new file mode 100644
index 0000000..64ad900
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
@@ -0,0 +1,209 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.server.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.AbstractModule;
+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.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests {@link ExternalIdUpsertPreprocessor}. */
+@TestPlugin(
+    name = "external-id-update-preprocessor",
+    sysModule =
+        "com.google.gerrit.acceptance.server.notedb.ExternalIdNotesUpsertPreprocessorIT$Module")
+public class ExternalIdNotesUpsertPreprocessorIT extends LightweightPluginDaemonTest {
+  @Inject private Sequences sequences;
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+  @Inject private Accounts accounts;
+
+  public static class Module extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExternalIdUpsertPreprocessor.class)
+          .annotatedWith(Exports.named("TestPreprocessor"))
+          .to(TestPreprocessor.class);
+    }
+  }
+
+  private TestPreprocessor testPreprocessor;
+
+  @Before
+  public void setUp() {
+    testPreprocessor = plugin.getSysInjector().getInstance(TestPreprocessor.class);
+    testPreprocessor.reset();
+  }
+
+  @Test
+  public void insertAccount() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId));
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void replaceByKeys() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar1", id);
+    ExternalId extId2 = ExternalId.create("foo", "bar2", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replaceByKeys(ImmutableSet.of(extId1.key()), ImmutableSet.of(extId2));
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void insert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.insert(extId);
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void upsert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.upsert(extId);
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void replace() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar1", id);
+    ExternalId extId2 = ExternalId.create("foo", "bar2", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replace(ImmutableSet.of(extId1), ImmutableSet.of(extId2));
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void replace_viaAccountsUpdate() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar", id, "email1@foo", "hash");
+    ExternalId extId2 = ExternalId.create("foo", "bar", id, "email2@foo", "hash");
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    accountsUpdateProvider.get().update("test", id, u -> u.updateExternalId(extId2));
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void blockUpsert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = ExternalId.create("foo", "bar", id);
+    testPreprocessor.throwException = true;
+    StorageException e =
+        assertThrows(
+            StorageException.class,
+            () -> accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId)));
+    assertThat(e).hasMessageThat().contains("upsert not good");
+    assertThat(testPreprocessor.upserted).isEmpty();
+  }
+
+  @Test
+  public void blockUpsert_replace() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = ExternalId.create("foo", "bar", id, "email1@foo", "hash");
+    ExternalId extId2 = ExternalId.create("foo", "bar", id, "email2@foo", "hash");
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    assertThat(accounts.get(id).get().externalIds()).containsExactly(extId1);
+
+    testPreprocessor.reset();
+    testPreprocessor.throwException = true;
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replace(ImmutableSet.of(extId1), ImmutableSet.of(extId2));
+      StorageException e = assertThrows(StorageException.class, () -> extIdNotes.commit(md));
+      assertThat(e).hasMessageThat().contains("upsert not good");
+    }
+    assertThat(testPreprocessor.upserted).isEmpty();
+    assertThat(accounts.get(id).get().externalIds()).containsExactly(extId1);
+  }
+
+  @Singleton
+  public static class TestPreprocessor implements ExternalIdUpsertPreprocessor {
+    List<ExternalId> upserted = new ArrayList<>();
+
+    boolean throwException = false;
+
+    @Override
+    public void upsert(ExternalId extId) {
+      assertThat(extId.blobId()).isNotNull();
+      if (throwException) {
+        throw new StorageException("upsert not good");
+      }
+      upserted.add(extId);
+    }
+
+    void reset() {
+      upserted.clear();
+      throwException = false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index ae8e06d..8c5e449 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -60,7 +60,6 @@
         Entities.Change.newBuilder()
             .setChangeId(Entities.Change_Id.newBuilder().setId(14))
             .setChangeKey(Entities.Change_Key.newBuilder().setId("change 1"))
-            .setRowVersion(0)
             .setCreatedOn(987654L)
             .setLastUpdatedOn(1234567L)
             .setOwnerAccountId(Entities.Account_Id.newBuilder().setId(35))
@@ -109,7 +108,6 @@
                     .setBranch("refs/heads/branch-74"))
             // Default values which can't be unset.
             .setCurrentPatchSetId(0)
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -147,7 +145,6 @@
                     .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(0)
             // Default values which can't be unset.
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -185,7 +182,6 @@
             .setCurrentPatchSetId(23)
             .setSubject("subject ABC")
             // Default values which can't be unset.
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -251,7 +247,6 @@
     assertThat(change.getSubject()).isNull();
     assertThat(change.currentPatchSetId()).isNull();
     // Default values for unset protobuf fields which can't be unset in the entity object.
-    assertThat(change.getRowVersion()).isEqualTo(0);
     assertThat(change.isNew()).isTrue();
     assertThat(change.isPrivate()).isFalse();
     assertThat(change.isWorkInProgress()).isFalse();
@@ -284,7 +279,6 @@
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("changeKey", Change.Key.class)
-                .put("rowVersion", int.class)
                 .put("createdOn", Timestamp.class)
                 .put("lastUpdatedOn", Timestamp.class)
                 .put("owner", Account.Id.class)
@@ -309,7 +303,6 @@
   private static void assertEqualChange(Change change, Change expectedChange) {
     assertThat(change.getChangeId()).isEqualTo(expectedChange.getChangeId());
     assertThat(change.getKey()).isEqualTo(expectedChange.getKey());
-    assertThat(change.getRowVersion()).isEqualTo(expectedChange.getRowVersion());
     assertThat(change.getCreatedOn()).isEqualTo(expectedChange.getCreatedOn());
     assertThat(change.getLastUpdatedOn()).isEqualTo(expectedChange.getLastUpdatedOn());
     assertThat(change.getOwner()).isEqualTo(expectedChange.getOwner());
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 287a7fe..10599c6 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -64,6 +64,7 @@
             cfg.setInt("change", null, "maxFiles", 2);
             cfg.setInt("change", null, "maxPatchSets", MAX_PATCH_SETS);
             cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
+            cfg.setString("index", null, "type", "fake");
             return cfg;
           });
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
index 5b248da..98956c9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_html.ts
@@ -63,6 +63,7 @@
     table {
       border-collapse: collapse;
       table-layout: fixed;
+      background-color: var(--diff-blank-background-color);
     }
 
     /*
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
index b26ec9b..bbc7b1a 100644
--- a/polygerrit-ui/app/services/comments/comments-model.ts
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -111,7 +111,7 @@
   if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
   else drafts[draft.path] = [...drafts[draft.path]];
   const index = drafts[draft.path].findIndex(
-    d => d.__draftID === draft.__draftID || d.id === draft.id
+    d => (d.__draftID && d.__draftID === draft.__draftID) || d.id === draft.id
   );
   if (index !== -1) {
     drafts[draft.path][index] = draft;
@@ -127,7 +127,7 @@
   nextState.drafts = {...nextState.drafts};
   const drafts = nextState.drafts;
   const index = (drafts[draft.path] || []).findIndex(
-    d => d.__draftID === draft.__draftID || d.id === draft.id
+    d => (d.__draftID && d.__draftID === draft.__draftID) || d.id === draft.id
   );
   if (index === -1) return;
   drafts[draft.path] = [...drafts[draft.path]];
diff --git a/proto/entities.proto b/proto/entities.proto
index 84c7fbd..d4ff736 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -35,7 +35,6 @@
 message Change {
   required Change_Id change_id = 1;
   optional Change_Key change_key = 2;
-  optional int32 row_version = 3;
   optional fixed64 created_on = 4;
   optional fixed64 last_updated_on = 5;
   optional Account_Id owner_account_id = 7;
@@ -54,6 +53,7 @@
   optional PatchSet_Id cherry_pick_of = 24;
 
   // Deleted fields, should not be reused:
+  reserved 3;    // row_version
   reserved 6;    // sortkey
   reserved 9;    // open
   reserved 11;   // nbrPatchSets