Merge "Track generated suggestion edits only when suggestions remain"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 091d229..ec02cdd 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5795,7 +5795,7 @@
   Content-Disposition: attachment
   Content-Type: text/plain; charset=UTF-8
 
-  The existing change edit could not be merged with another tree.
+  Rebasing change edit onto another patchset results in merge conflicts. Download the edit patchset and rebase manually to preserve changes.
 ----
 
 [[apply-provided-fix]]
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index 56fb748..fad3aa8 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -433,6 +433,9 @@
   /** Locally assigned unique identifier of the change */
   private Id changeId;
 
+  /** ServerId of the Gerrit instance that has created the change */
+  private String serverId;
+
   /** Globally assigned unique identifier of the change */
   private Key changeKey;
 
@@ -530,6 +533,22 @@
     return changeId;
   }
 
+  /**
+   * Set the serverId of the Gerrit instance that created the change. It can be set to null for
+   * testing purposes in the protobuf converter tests.
+   */
+  public void setServerId(@Nullable String serverId) {
+    this.serverId = serverId;
+  }
+
+  /**
+   * ServerId of the Gerrit instance that created the change. It could be null when the change is
+   * not fetched from NoteDb but obtained through protobuf deserialisation.
+   */
+  public @Nullable String getServerId() {
+    return serverId;
+  }
+
   /** 32 bit integer identity for a change. */
   public int getChangeId() {
     return changeId.get();
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 33a2f34..4a3b423 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -102,6 +102,7 @@
   public Boolean containsGitConflicts;
 
   public Integer _number;
+  public Integer _virtualIdNumber;
 
   public AccountInfo owner;
 
diff --git a/java/com/google/gerrit/index/QueryOptions.java b/java/com/google/gerrit/index/QueryOptions.java
index bee8fa1..632e469 100644
--- a/java/com/google/gerrit/index/QueryOptions.java
+++ b/java/com/google/gerrit/index/QueryOptions.java
@@ -204,4 +204,8 @@
         allowIncompleteResults(),
         filter.apply(this));
   }
+
+  public int getLimitBasedOnPaginationType() {
+    return PaginationType.NONE == config().paginationType() ? limit() : pageSize();
+  }
 }
diff --git a/java/com/google/gerrit/index/query/FilteredSource.java b/java/com/google/gerrit/index/query/FilteredSource.java
index 9746850..95e6435 100644
--- a/java/com/google/gerrit/index/query/FilteredSource.java
+++ b/java/com/google/gerrit/index/query/FilteredSource.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
 import java.util.ArrayList;
 import java.util.List;
 
@@ -51,11 +52,36 @@
     return new LazyResultSet<>(
         () -> {
           List<T> r = new ArrayList<>();
+          T last = null;
+          int pageResultSize = 0;
           for (T data : buffer(resultSet)) {
             if (!isMatchable() || match(data)) {
               r.add(data);
             }
+            last = data;
+            pageResultSize++;
           }
+
+          if (last != null && source instanceof Paginated) {
+            // Restart source and continue if we have not filled the
+            // full limit the caller wants.
+            Paginated<T> p = (Paginated<T>) source;
+            QueryOptions opts = p.getOptions();
+            final int limit = opts.limit();
+            int nextStart = pageResultSize;
+            while (pageResultSize == limit && r.size() < limit) {
+              ResultSet<T> next = p.restart(nextStart);
+              pageResultSize = 0;
+              for (T data : buffer(next)) {
+                if (match(data)) {
+                  r.add(data);
+                }
+                pageResultSize++;
+              }
+              nextStart += pageResultSize;
+            }
+          }
+
           if (start >= r.size()) {
             return ImmutableList.of();
           } else if (start > 0) {
diff --git a/java/com/google/gerrit/index/query/IndexedQuery.java b/java/com/google/gerrit/index/query/IndexedQuery.java
index ee25ef9..cb98c06 100644
--- a/java/com/google/gerrit/index/query/IndexedQuery.java
+++ b/java/com/google/gerrit/index/query/IndexedQuery.java
@@ -87,6 +87,12 @@
   }
 
   @Override
+  public ResultSet<T> restart(int start) {
+    opts = opts.withStart(start);
+    return search();
+  }
+
+  @Override
   public ResultSet<T> restart(int start, int pageSize) {
     opts = opts.withStart(start).withPageSize(pageSize);
     return search();
diff --git a/java/com/google/gerrit/index/query/Paginated.java b/java/com/google/gerrit/index/query/Paginated.java
index 5521990..1065019 100644
--- a/java/com/google/gerrit/index/query/Paginated.java
+++ b/java/com/google/gerrit/index/query/Paginated.java
@@ -19,6 +19,8 @@
 public interface Paginated<T> {
   QueryOptions getOptions();
 
+  ResultSet<T> restart(int start);
+
   ResultSet<T> restart(int start, int pageSize);
 
   ResultSet<T> restart(Object searchAfter, int pageSize);
diff --git a/java/com/google/gerrit/index/query/PaginatingSource.java b/java/com/google/gerrit/index/query/PaginatingSource.java
index be789ee..19251ca 100644
--- a/java/com/google/gerrit/index/query/PaginatingSource.java
+++ b/java/com/google/gerrit/index/query/PaginatingSource.java
@@ -54,7 +54,6 @@
           if (last != null && source instanceof Paginated) {
             // Restart source and continue if we have not filled the
             // full limit the caller wants.
-            //
             @SuppressWarnings("unchecked")
             Paginated<T> p = (Paginated<T>) source;
             QueryOptions opts = p.getOptions();
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
index 65c02d9..570290e 100644
--- a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -150,10 +150,14 @@
                     .findFirst()
                     .orElse(-1)
                 + 1;
-        int toIndex = Math.min(fromIndex + opts.pageSize(), valueList.size());
+        int toIndex = Math.min(fromIndex + opts.getLimitBasedOnPaginationType(), valueList.size());
         results = valueList.subList(fromIndex, toIndex);
       } else {
-        results = valueStream.skip(opts.start()).limit(opts.pageSize()).collect(toImmutableList());
+        results =
+            valueStream
+                .skip(opts.start())
+                .limit(opts.getLimitBasedOnPaginationType())
+                .collect(toImmutableList());
       }
       queryCount++;
       resultsSizes.add(results.size());
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 9fcf5ae..ffd25ba 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
+import static com.google.gerrit.server.index.change.ChangeField.CHANGENUM_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -113,7 +114,7 @@
   private static final String CHANGE_FIELD = ChangeField.CHANGE_SPEC.getName();
 
   static Term idTerm(ChangeData cd) {
-    return idTerm(cd.getVirtualId());
+    return idTerm(cd.virtualId());
   }
 
   static Term idTerm(Change.Id id) {
@@ -405,11 +406,11 @@
       IndexSearcher[] searchers = new IndexSearcher[indexes.size()];
       Map<ChangeSubIndex, ScoreDoc> searchAfterBySubIndex = new HashMap<>();
       try {
-        int realPageSize = opts.start() + opts.pageSize();
-        if (Integer.MAX_VALUE - opts.pageSize() < opts.start()) {
-          realPageSize = Integer.MAX_VALUE;
+        int pageLimit = AbstractLuceneIndex.getLimitBasedOnPaginationType(opts, opts.pageSize());
+        int queryLimit = opts.start() + pageLimit;
+        if (Integer.MAX_VALUE - pageLimit < opts.start()) {
+          queryLimit = Integer.MAX_VALUE;
         }
-        int queryLimit = AbstractLuceneIndex.getLimitBasedOnPaginationType(opts, realPageSize);
         List<TopFieldDocs> hits = new ArrayList<>();
         int searchAfterHitsCount = 0;
         for (int i = 0; i < indexes.size(); i++) {
@@ -417,7 +418,7 @@
           searchers[i] = subIndex.acquire();
           if (isSearchAfterPagination) {
             ScoreDoc searchAfter = getSearchAfter(subIndex);
-            int maxRemainingHits = realPageSize - searchAfterHitsCount;
+            int maxRemainingHits = queryLimit - searchAfterHitsCount;
             if (maxRemainingHits > 0) {
               TopFieldDocs subIndexHits =
                   searchers[i].searchAfter(
@@ -528,7 +529,11 @@
         ImmutableList.Builder<ChangeData> result =
             ImmutableList.builderWithExpectedSize(docs.size());
         for (Document doc : docs) {
-          result.add(toChangeData(fields(doc, fields), fields, NUMERIC_ID_STR_SPEC.getName()));
+          String fieldName =
+              doc.getField(CHANGENUM_SPEC.getName()) != null
+                  ? CHANGENUM_SPEC.getName()
+                  : NUMERIC_ID_STR_SPEC.getName();
+          result.add(toChangeData(fields(doc, fields), fields, fieldName));
         }
         return result.build();
       } catch (InterruptedException e) {
@@ -571,7 +576,13 @@
     IndexableField cb = Iterables.getFirst(doc.get(CHANGE_FIELD), null);
     if (cb != null) {
       BytesRef proto = cb.binaryValue();
-      cd = changeDataFactory.create(parseProtoFrom(proto, ChangeProtoConverter.INSTANCE));
+      // pass the id field value (which is the change virtual id for the imported changes) when
+      // available
+      IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
+      cd =
+          changeDataFactory.create(
+              parseProtoFrom(proto, ChangeProtoConverter.INSTANCE),
+              f != null ? Change.id(Integer.valueOf(f.stringValue())) : null);
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
 
diff --git a/java/com/google/gerrit/server/ChangeDraftUpdate.java b/java/com/google/gerrit/server/ChangeDraftUpdate.java
index d9cea31..a3b13e5 100644
--- a/java/com/google/gerrit/server/ChangeDraftUpdate.java
+++ b/java/com/google/gerrit/server/ChangeDraftUpdate.java
@@ -80,7 +80,9 @@
   default <UpdateT extends ChangeDraftUpdate> Optional<UpdateT> toOptionalChangeDraftUpdateSubtype(
       Class<UpdateT> subtype) {
     if (this.getClass().isAssignableFrom(subtype)) {
-      return Optional.of((UpdateT) this);
+      @SuppressWarnings("unchecked")
+      UpdateT update = (UpdateT) this;
+      return Optional.of(update);
     }
     return Optional.empty();
   }
diff --git a/java/com/google/gerrit/server/account/AccountResource.java b/java/com/google/gerrit/server/account/AccountResource.java
index 9629809..14b363b 100644
--- a/java/com/google/gerrit/server/account/AccountResource.java
+++ b/java/com/google/gerrit/server/account/AccountResource.java
@@ -99,6 +99,10 @@
     public Change getChange() {
       return change.getChange();
     }
+
+    public Change.Id getVirtualId() {
+      return change.getVirtualId();
+    }
   }
 
   public static class Star implements RestResource {
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index 9f253de..5668c27 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -208,6 +208,12 @@
     return Optional.of(notes.get(0));
   }
 
+  /**
+   * @deprecated this method is not reliable in Gerrit instances with imported changes, since
+   *     multiple changes can have the same change number and make the `changeIdProjectCache` cache
+   *     pointless.
+   */
+  @Deprecated(since = "3.10", forRemoval = true)
   public List<ChangeNotes> find(Change.Id id) {
     String project = changeIdProjectCache.getIfPresent(id);
     if (project != null) {
@@ -245,7 +251,7 @@
     // this case.)
     Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
     for (ChangeData cd : cds) {
-      if (seen.add(cd.getId())) {
+      if (seen.add(cd.virtualId())) {
         try {
           notes.add(cd.notes());
         } catch (NoSuchChangeException e) {
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index d0f5d6b..0abe9cf 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -63,7 +63,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitRecord.Status;
 import com.google.gerrit.entities.SubmitRequirementResult;
@@ -86,7 +85,6 @@
 import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -513,6 +511,9 @@
         if (has(REVIEWED) && userProvider.get().isIdentifiedUser()) {
           ChangeData.ensureReviewedByLoadedForOpenChanges(all);
         }
+        if (has(STAR) && userProvider.get().isIdentifiedUser()) {
+          ChangeData.ensureChangeServerId(all);
+        }
         ChangeData.ensureCurrentApprovalsLoaded(all);
       }
     } else {
@@ -549,7 +550,8 @@
           }
           continue;
         }
-        ChangeInfo info = cache.get(cd.getId());
+        Change.Id cdUniqueId = cd.virtualId();
+        ChangeInfo info = cache.get(cdUniqueId);
         if (info != null && isCacheable) {
           changeInfos.add(info);
           continue;
@@ -561,7 +563,7 @@
           info = format(cd, Optional.empty(), false, pluginInfosByChange.get(cd.getId()));
           changeInfos.add(info);
           if (isCacheable) {
-            cache.put(Change.id(info._number), info);
+            cache.put(cdUniqueId, info);
           }
         } catch (RuntimeException e) {
           Optional<RequestCancelledException> requestCancelledException =
@@ -802,6 +804,8 @@
       }
     }
 
+    out._virtualIdNumber = cd.virtualId().get();
+
     return out;
   }
 
@@ -868,14 +872,8 @@
         TraceContext.newTimer(
             "Get change meta ref",
             Metadata.builder().changeId(cd.change().getId().get()).build())) {
-      if (cd.getRefStates() != null) {
-        String metaName = RefNames.changeMetaRef(cd.getId());
-        Optional<RefState> metaState =
-            cd.getRefStates().values().stream().filter(r -> r.ref().equals(metaName)).findAny();
-        return metaState.map(RefState::id);
-      }
+      return cd.metaRevision();
     }
-    return Optional.empty();
   }
 
   private Boolean isReviewedByCurrentUser(ChangeData cd, CurrentUser user) {
@@ -1071,14 +1069,15 @@
     // repository only once
     try (Repository allUsersRepo = repoManager.openRepository(allUsers)) {
       List<Change.Id> changeIds =
-          changeInfos.stream().map(c -> Change.id(c._number)).collect(Collectors.toList());
+          changeInfos.stream().map(c -> Change.id(c._virtualIdNumber)).collect(Collectors.toList());
       Set<Change.Id> starredChanges =
           starredChangesreader.areStarred(
               allUsersRepo, changeIds, userProvider.get().asIdentifiedUser().getAccountId());
       if (starredChanges.isEmpty()) {
         return;
       }
-      changeInfos.stream().forEach(c -> c.starred = starredChanges.contains(Change.id(c._number)));
+      changeInfos.stream()
+          .forEach(c -> c.starred = starredChanges.contains(Change.id(c._virtualIdNumber)));
     } catch (IOException e) {
       logger.atWarning().withCause(e).log("Failed to open All-Users repo.");
     }
@@ -1091,7 +1090,11 @@
   private ImmutableListMultimap<Change.Id, PluginDefinedInfo> getPluginInfos(
       Collection<ChangeData> cds) {
     if (pluginDefinedInfosFactory.isPresent()) {
-      return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Get plugin infos", Metadata.builder().resourceCount(cds.size()).build())) {
+        return pluginDefinedInfosFactory.get().createPluginDefinedInfos(cds);
+      }
     }
     return ImmutableListMultimap.of();
   }
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index dc09bf4..8300541 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -161,6 +161,10 @@
     return changeData;
   }
 
+  public Change.Id getVirtualId() {
+    return getChangeData().virtualId();
+  }
+
   // This includes all information relevant for ETag computation
   // unrelated to the UI.
   public void prepareETag(Hasher h, CurrentUser user) {
@@ -237,7 +241,7 @@
                 .build())) {
       Hasher h = Hashing.murmur3_128().newHasher();
       if (user.isIdentifiedUser()) {
-        h.putBoolean(starredChangesReader.isStarred(user.getAccountId(), getId()));
+        h.putBoolean(starredChangesReader.isStarred(user.getAccountId(), getVirtualId()));
       }
       prepareETag(h, user);
       return h.hash().toString();
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index ba7bd90a..bcfa48a 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -81,7 +81,8 @@
     ensureDeletable(ctx, id, patchSets);
     // Cleaning up is only possible as long as the change and its elements are
     // still part of the database.
-    cleanUpReferences(id);
+    ChangeData cd = changeDataFactory.create(ctx.getChange());
+    cleanUpReferences(cd);
 
     logger.atFine().log(
         "Deleting change %s, current patch set %d is commit %s",
@@ -95,7 +96,7 @@
                     .map(p -> p.commitId().name())
                     .orElse("n/a")));
     ctx.deleteChange();
-    changeDeleted.fire(changeDataFactory.create(ctx.getChange()), ctx.getAccount(), ctx.getWhen());
+    changeDeleted.fire(cd, ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
@@ -124,11 +125,11 @@
         revWalk.parseCommit(patchSet.commitId()), revWalk.parseCommit(destId.get()));
   }
 
-  private void cleanUpReferences(Change.Id id) throws IOException {
-    accountPatchReviewStore.run(s -> s.clearReviewed(id));
+  private void cleanUpReferences(ChangeData cd) throws IOException {
+    accountPatchReviewStore.run(s -> s.clearReviewed(cd.virtualId()));
 
     // Non-atomic operation on All-Users refs; not much we can do to make it atomic.
-    starredChangesWriter.unstarAllForChangeDeletion(id);
+    starredChangesWriter.unstarAllForChangeDeletion(cd.virtualId());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index d9a118d..565d471 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -566,7 +566,8 @@
 
     if (!successful) {
       throw new MergeConflictException(
-          "The existing change edit could not be merged with another tree.");
+          "Rebasing change edit onto another patchset results in merge conflicts. Download the edit"
+              + " patchset and rebase manually to preserve changes.");
     }
     return threeWayMerger.getResultTreeId();
   }
diff --git a/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index dfc1ffb..572001d 100644
--- a/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -18,9 +18,11 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.UsedAt;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -39,26 +41,41 @@
 
   private final ObjectId baseTreeId;
   private final ImmutableList<? extends ObjectId> baseParents;
+  private final Optional<ObjectInserter> objectInserter;
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public static TreeCreator basedOn(RevCommit baseCommit) {
     requireNonNull(baseCommit, "baseCommit is required");
-    return new TreeCreator(baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()));
+    return new TreeCreator(
+        baseCommit.getTree(), ImmutableList.copyOf(baseCommit.getParents()), Optional.empty());
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static TreeCreator basedOn(RevCommit baseCommit, ObjectInserter objectInserter) {
+    requireNonNull(baseCommit, "baseCommit is required");
+    return new TreeCreator(
+        baseCommit.getTree(),
+        ImmutableList.copyOf(baseCommit.getParents()),
+        Optional.of(objectInserter));
   }
 
   public static TreeCreator basedOnTree(
       ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
     requireNonNull(baseTreeId, "baseTreeId is required");
-    return new TreeCreator(baseTreeId, baseParents);
+    return new TreeCreator(baseTreeId, baseParents, Optional.empty());
   }
 
   public static TreeCreator basedOnEmptyTree() {
-    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of());
+    return new TreeCreator(ObjectId.zeroId(), ImmutableList.of(), Optional.empty());
   }
 
-  private TreeCreator(ObjectId baseTreeId, ImmutableList<? extends ObjectId> baseParents) {
+  private TreeCreator(
+      ObjectId baseTreeId,
+      ImmutableList<? extends ObjectId> baseParents,
+      Optional<ObjectInserter> objectInserter) {
     this.baseTreeId = requireNonNull(baseTreeId, "baseTree is required");
     this.baseParents = baseParents;
+    this.objectInserter = objectInserter;
   }
 
   /**
@@ -141,17 +158,22 @@
     return pathEdits;
   }
 
+  private ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
+    ObjectInserter oi = objectInserter.orElse(repository.newObjectInserter());
+    try {
+      ObjectId treeId = tree.writeTree(oi);
+      oi.flush();
+      return treeId;
+    } finally {
+      if (objectInserter.isEmpty()) {
+        oi.close();
+      }
+    }
+  }
+
   private static void applyPathEdits(DirCache tree, List<DirCacheEditor.PathEdit> pathEdits) {
     DirCacheEditor dirCacheEditor = tree.editor();
     pathEdits.forEach(dirCacheEditor::add);
     dirCacheEditor.finish();
   }
-
-  private static ObjectId writeAndGetId(Repository repository, DirCache tree) throws IOException {
-    try (ObjectInserter objectInserter = repository.newObjectInserter()) {
-      ObjectId treeId = tree.writeTree(objectInserter);
-      objectInserter.flush();
-      return treeId;
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index 83024e3..d8afbbb 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -155,13 +155,14 @@
                 .byProject(key);
         Map<Change.Id, CachedChange> result = new HashMap<>(cds.size());
         for (ChangeData cd : cds) {
-          if (result.containsKey(cd.getId())) {
+          final Change.Id cdUniqueId = cd.virtualId();
+          if (result.containsKey(cdUniqueId)) {
             logger.atWarning().log(
                 "Duplicate changes returned from change query by project %s: %s, %s",
-                key, cd.change(), result.get(cd.getId()).change());
+                key, cd.change(), result.get(cdUniqueId).change());
           }
           result.put(
-              cd.getId(),
+              cdUniqueId,
               new AutoValue_SearchingChangeCacheImpl_CachedChange(cd.change(), cd.reviewers()));
         }
         return List.copyOf(result.values());
diff --git a/java/com/google/gerrit/server/index/IndexUtils.java b/java/com/google/gerrit/server/index/IndexUtils.java
index f2a2904..f81a9ce 100644
--- a/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/java/com/google/gerrit/server/index/IndexUtils.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+import static com.google.gerrit.server.index.change.ChangeField.CHANGENUM_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.CHANGE_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.NUMERIC_ID_STR_SPEC;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT_SPEC;
@@ -81,10 +82,18 @@
       // A Change is always sufficient.
       return fs;
     }
-    if (fs.contains(PROJECT_SPEC.getName()) && fs.contains(NUMERIC_ID_STR_SPEC.getName())) {
+
+    Set<String> requiredFields =
+        CHANGENUM_SPEC.getName() != null
+            ? ImmutableSet.of(
+                NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName(), CHANGENUM_SPEC.getName())
+            : ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName());
+
+    if (fs.containsAll(requiredFields)) {
       return fs;
     }
-    return Sets.union(fs, ImmutableSet.of(NUMERIC_ID_STR_SPEC.getName(), PROJECT_SPEC.getName()));
+
+    return Sets.union(fs, ImmutableSet.copyOf(requiredFields));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index c5afba4..045482a 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_CHANGE_NUMBER;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
@@ -128,11 +129,20 @@
           .required()
           // The numeric change id is integer in string form
           .size(10)
-          .build(cd -> String.valueOf(cd.getVirtualId().get()));
+          .build(cd -> String.valueOf(cd.virtualId().get()));
 
   public static final IndexedField<ChangeData, String>.SearchSpec NUMERIC_ID_STR_SPEC =
       NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
 
+  public static final IndexedField<ChangeData, Integer> CHANGENUM_FIELD =
+      IndexedField.<ChangeData>integerBuilder("ChangeNumber")
+          .stored()
+          .required()
+          .build(cd -> cd.getId().get());
+
+  public static final IndexedField<ChangeData, Integer>.SearchSpec CHANGENUM_SPEC =
+      CHANGENUM_FIELD.integer(FIELD_CHANGE_NUMBER);
+
   /** Newer style Change-Id key. */
   public static final IndexedField<ChangeData, String> CHANGE_ID_FIELD =
       IndexedField.<ChangeData>stringBuilder("ChangeId")
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 3d48907..4921b3f 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -259,8 +259,15 @@
           .build();
 
   /** Upgrade Lucene to 9.x requires reindexing. */
-  @SuppressWarnings("deprecation")
-  static final Schema<ChangeData> V85 = schema(V84);
+  @Deprecated static final Schema<ChangeData> V85 = schema(V84);
+
+  /** Add ChangeNumber field */
+  static final Schema<ChangeData> V86 =
+      new Schema.Builder<ChangeData>()
+          .add(V85)
+          .addIndexedFields(ChangeField.CHANGENUM_FIELD)
+          .addSearchSpecs(ChangeField.CHANGENUM_SPEC)
+          .build();
 
   /**
    * Name of the change index to be used when contacting index backends or loading configurations.
diff --git a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index f7ff13c..00642a9 100644
--- a/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -21,7 +21,6 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.index.IndexConfig;
@@ -52,10 +51,6 @@
  */
 public class IndexedChangeQuery extends IndexedQuery<Change.Id, ChangeData>
     implements ChangeDataSource, Matchable<ChangeData> {
-  public static QueryOptions oneResult() {
-    IndexConfig config = IndexConfig.createDefault();
-    return createOptions(config, 0, 1, config.pageSizeMultiplier(), 1, ImmutableSet.of());
-  }
 
   public static QueryOptions createOptions(
       IndexConfig config, int start, int limit, Set<String> fields) {
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
index 046eeb3..07d9b90 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogRecord.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.auto.value.AutoValue;
+import java.time.Instant;
 import java.util.Optional;
 
 /**
@@ -33,7 +34,8 @@
    * @return the performance log record
    */
   public static PerformanceLogRecord create(String operation, long durationMs) {
-    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.empty());
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationMs, Instant.now(), Optional.empty());
   }
 
   /**
@@ -45,20 +47,23 @@
    * @return the performance log record
    */
   public static PerformanceLogRecord create(String operation, long durationMs, Metadata metadata) {
-    return new AutoValue_PerformanceLogRecord(operation, durationMs, Optional.of(metadata));
+    return new AutoValue_PerformanceLogRecord(
+        operation, durationMs, Instant.now(), Optional.of(metadata));
   }
 
   public abstract String operation();
 
   public abstract long durationMs();
 
+  public abstract Instant endTime();
+
   public abstract Optional<Metadata> metadata();
 
   void writeTo(PerformanceLogger performanceLogger) {
     if (metadata().isPresent()) {
-      performanceLogger.log(operation(), durationMs(), metadata().get());
+      performanceLogger.log(operation(), durationMs(), endTime(), metadata().get());
     } else {
-      performanceLogger.log(operation(), durationMs());
+      performanceLogger.log(operation(), durationMs(), endTime());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogger.java b/java/com/google/gerrit/server/logging/PerformanceLogger.java
index 74a1684..bed53ba 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogger.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogger.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.logging;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.time.Instant;
 
 /**
  * Extension point for logging performance records.
@@ -35,8 +36,8 @@
    * @param operation operation that was performed
    * @param durationMs time that the execution of the operation took (in milliseconds)
    */
-  default void log(String operation, long durationMs) {
-    log(operation, durationMs, Metadata.empty());
+  default void log(String operation, long durationMs, Instant endTime) {
+    log(operation, durationMs, endTime, Metadata.empty());
   }
 
   /**
@@ -46,5 +47,5 @@
    * @param durationMs time that the execution of the operation took (in milliseconds)
    * @param metadata metadata
    */
-  void log(String operation, long durationMs, Metadata metadata);
+  void log(String operation, long durationMs, Instant endTime, Metadata metadata);
 }
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index df30573..6c38210 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -252,7 +252,7 @@
           queryProvider
               .get()
               .enforceVisibility(true)
-              .byLegacyChangeId(Change.id(metadata.changeNumber));
+              .byChangeNumber(Change.id(metadata.changeNumber));
       if (changeDataList.isEmpty()) {
         sendRejectionEmail(message, InboundEmailError.CHANGE_NOT_FOUND);
         return;
diff --git a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
index 1edc7bd..4bb347a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeDraftNotesUpdate.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.gerrit.server.update.BatchUpdateListener;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -73,6 +74,8 @@
  * <p>This class is not thread safe.
  */
 public class ChangeDraftNotesUpdate extends AbstractChangeUpdate implements ChangeDraftUpdate {
+  private final ChangeNumberVirtualIdAlgorithm virtualIdFunc;
+
   public interface Factory extends ChangeDraftUpdateFactory {
     @Override
     ChangeDraftNotesUpdate create(
@@ -234,6 +237,7 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       ExperimentFeatures experimentFeatures,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted ChangeNotes notes,
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
@@ -242,6 +246,7 @@
     super(noteUtil, serverIdent, notes, null, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
     this.experimentFeatures = experimentFeatures;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   @AssistedInject
@@ -250,6 +255,7 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       ExperimentFeatures experimentFeatures,
+      @Nullable ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @Assisted Change change,
       @Assisted("effective") Account.Id accountId,
       @Assisted("real") Account.Id realAccountId,
@@ -258,6 +264,7 @@
     super(noteUtil, serverIdent, null, change, accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
     this.experimentFeatures = experimentFeatures;
+    this.virtualIdFunc = virtualIdFunc;
   }
 
   @Override
@@ -320,6 +327,7 @@
             draftsProject,
             noteUtil,
             experimentFeatures,
+            virtualIdFunc,
             new Change(getChange()),
             accountId,
             realAccountId,
@@ -430,7 +438,7 @@
 
   @Override
   protected String getRefName() {
-    return RefNames.refsDraftComments(getId(), accountId);
+    return RefNames.refsDraftComments(getVirtualId(), accountId);
   }
 
   @Override
@@ -447,4 +455,11 @@
   public boolean isEmpty() {
     return delete.isEmpty() && put.isEmpty();
   }
+
+  private Change.Id getVirtualId() {
+    Change change = getChange();
+    return virtualIdFunc == null
+        ? change.getId()
+        : virtualIdFunc.apply(change.getServerId(), change.getId());
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 7f0b068..97d65be 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -214,23 +214,6 @@
       return changes.get(0).notes();
     }
 
-    /**
-     * Create change notes based on a list of {@link com.google.gerrit.entities.Change.Id}s. This
-     * requires using the Change index and should only be used when {@link
-     * com.google.gerrit.entities.Project.NameKey} and the numeric change ID are not available.
-     */
-    public List<ChangeNotes> createUsingIndexLookup(Collection<Change.Id> changeIds) {
-      List<ChangeNotes> notes = new ArrayList<>();
-      for (Change.Id changeId : changeIds) {
-        try {
-          notes.add(createCheckedUsingIndexLookup(changeId));
-        } catch (NoSuchChangeException e) {
-          // Ignore missing changes to match Access#get(Iterable) behavior.
-        }
-      }
-      return notes;
-    }
-
     public List<ChangeNotes> create(
         Repository repo,
         Project.NameKey project,
@@ -550,11 +533,21 @@
   }
 
   public ImmutableList<HumanComment> getDraftComments(Account.Id author) {
-    return getDraftComments(author, null);
+    return getDraftComments(author, null, null);
   }
 
-  ImmutableList<HumanComment> getDraftComments(Account.Id author, @Nullable Ref ref) {
-    loadDraftComments(author, ref);
+  public ImmutableList<HumanComment> getDraftComments(Account.Id author, @Nullable Ref ref) {
+    return getDraftComments(author, null, ref);
+  }
+
+  public ImmutableList<HumanComment> getDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId) {
+    return getDraftComments(author, virtualId, null);
+  }
+
+  ImmutableList<HumanComment> getDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId, @Nullable Ref ref) {
+    loadDraftComments(author, virtualId, ref);
     // Filter out any zombie draft comments. These are drafts that are also in
     // the published map, and arise when the update to All-Users to delete them
     // during the publish operation failed.
@@ -573,9 +566,10 @@
    * However, this method will load the comments if no draft comments have been loaded or if the
    * caller would like the drafts for another author.
    */
-  private void loadDraftComments(Account.Id author, @Nullable Ref ref) {
+  private void loadDraftComments(
+      Account.Id author, @Nullable Change.Id virtualId, @Nullable Ref ref) {
     if (draftCommentNotes == null || !author.equals(draftCommentNotes.getAuthor()) || ref != null) {
-      draftCommentNotes = new DraftCommentNotes(args, getChangeId(), author, ref);
+      draftCommentNotes = new DraftCommentNotes(args, getChangeId(), virtualId, author, ref);
       draftCommentNotes.load();
     }
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index e012942..eb6c15a 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -364,6 +364,7 @@
     change.setOwner(c.owner());
     change.setDest(BranchNameKey.create(change.getProject(), c.branch()));
     change.setCreatedOn(c.createdOn());
+    change.setServerId(serverId());
     copyNonConstructorColumnsTo(change);
   }
 
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 186b49a..639633e 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -42,8 +42,15 @@
 public class DraftCommentNotes extends AbstractChangeNotes<DraftCommentNotes> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final Change.Id virtualId;
+
   public interface Factory {
     DraftCommentNotes create(Change.Id changeId, Account.Id accountId);
+
+    DraftCommentNotes create(
+        @Assisted("changeId") Change.Id changeId,
+        @Assisted("virtualId") Change.Id virtualId,
+        Account.Id accountId);
   }
 
   private final Account.Id author;
@@ -54,11 +61,26 @@
 
   @AssistedInject
   DraftCommentNotes(Args args, @Assisted Change.Id changeId, @Assisted Account.Id author) {
-    this(args, changeId, author, null);
+    this(args, changeId, null, author, null);
   }
 
-  DraftCommentNotes(Args args, Change.Id changeId, Account.Id author, @Nullable Ref ref) {
+  @AssistedInject
+  DraftCommentNotes(
+      Args args,
+      @Assisted("changeId") Change.Id changeId,
+      @Assisted("virtualId") Change.Id virtualId,
+      @Assisted Account.Id author) {
+    this(args, changeId, virtualId, author, null);
+  }
+
+  DraftCommentNotes(
+      Args args,
+      Change.Id changeId,
+      @Nullable Change.Id virtualId,
+      Account.Id author,
+      @Nullable Ref ref) {
     super(args, changeId, null);
+    this.virtualId = virtualId;
     this.author = requireNonNull(author);
     this.ref = ref;
     if (ref != null) {
@@ -94,7 +116,7 @@
 
   @Override
   protected String getRefName() {
-    return refsDraftComments(getChangeId(), author);
+    return refsDraftComments(virtualId != null ? virtualId : getChangeId(), author);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
index 27c59f9..ea3dd0a 100644
--- a/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
+++ b/java/com/google/gerrit/server/notedb/DraftCommentsNotesReader.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.query.change.ChangeNumberVirtualIdAlgorithm;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -43,15 +44,18 @@
   private final DraftCommentNotes.Factory draftCommentNotesFactory;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
+  private final ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm;
 
   @Inject
   DraftCommentsNotesReader(
       DraftCommentNotes.Factory draftCommentNotesFactory,
       GitRepositoryManager repoManager,
-      AllUsersName allUsers) {
+      AllUsersName allUsers,
+      ChangeNumberVirtualIdAlgorithm virtualIdAlgorithm) {
     this.draftCommentNotesFactory = draftCommentNotesFactory;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
+    this.virtualIdAlgorithm = virtualIdAlgorithm;
   }
 
   @Override
@@ -64,7 +68,7 @@
 
   @Override
   public List<HumanComment> getDraftsByChangeAndDraftAuthor(ChangeNotes notes, Account.Id author) {
-    return sort(new ArrayList<>(notes.getDraftComments(author)));
+    return sort(new ArrayList<>(notes.getDraftComments(author, getVirtualId(notes))));
   }
 
   @Override
@@ -77,7 +81,7 @@
   public List<HumanComment> getDraftsByPatchSetAndDraftAuthor(
       ChangeNotes notes, PatchSet.Id psId, Account.Id author) {
     return sort(
-        notes.load().getDraftComments(author).stream()
+        notes.load().getDraftComments(author, getVirtualId(notes)).stream()
             .filter(c -> c.key.patchSetId == psId.get())
             .collect(Collectors.toList()));
   }
@@ -136,7 +140,7 @@
   private List<Ref> getDraftRefs(ChangeNotes notes) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       return repo.getRefDatabase()
-          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(notes.getChangeId()));
+          .getRefsByPrefix(RefNames.refsDraftCommentsPrefix(getVirtualId(notes)));
     } catch (IOException e) {
       throw new StorageException(e);
     }
@@ -145,4 +149,10 @@
   private List<HumanComment> sort(List<HumanComment> comments) {
     return CommentsUtil.sort(comments);
   }
+
+  private Change.Id getVirtualId(ChangeNotes notes) {
+    return virtualIdAlgorithm == null
+        ? notes.getChangeId()
+        : virtualIdAlgorithm.apply(notes.getServerId(), notes.getChangeId());
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
index c52f082..f13b832 100644
--- a/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
+++ b/java/com/google/gerrit/server/notedb/StarredChangesUtilNoteDbImpl.java
@@ -85,31 +85,31 @@
   }
 
   @Override
-  public boolean isStarred(Account.Id accountId, Change.Id changeId) {
+  public boolean isStarred(Account.Id accountId, Change.Id virtualId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getStarRef(repo, RefNames.refsStarredChanges(changeId, accountId)).isPresent();
+      return getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId)).isPresent();
     } catch (IOException e) {
       throw new StorageException(
           String.format(
               "Reading stars from change %d for account %d failed",
-              changeId.get(), accountId.get()),
+              virtualId.get(), accountId.get()),
           e);
     }
   }
 
   @Override
-  public void star(Account.Id accountId, Change.Id changeId) {
-    updateStar(accountId, changeId, true);
+  public void star(Account.Id accountId, Change.Id virtualId) {
+    updateStar(accountId, virtualId, true);
   }
 
   @Override
-  public void unstar(Account.Id accountId, Change.Id changeId) {
-    updateStar(accountId, changeId, false);
+  public void unstar(Account.Id accountId, Change.Id virtualId) {
+    updateStar(accountId, virtualId, false);
   }
 
-  private void updateStar(Account.Id accountId, Change.Id changeId, boolean shouldAdd) {
+  private void updateStar(Account.Id accountId, Change.Id virtualId, boolean shouldAdd) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      String refName = RefNames.refsStarredChanges(changeId, accountId);
+      String refName = RefNames.refsStarredChanges(virtualId, accountId);
       if (shouldAdd) {
         addRef(repo, refName, null);
       } else {
@@ -120,16 +120,16 @@
       }
     } catch (IOException e) {
       throw new StorageException(
-          String.format("Star change %d for account %d failed", changeId.get(), accountId.get()),
+          String.format("Star change %d for account %d failed", virtualId.get(), accountId.get()),
           e);
     }
   }
 
   @Override
   public Set<Change.Id> areStarred(
-      Repository allUsersRepo, List<Change.Id> changeIds, Account.Id caller) {
+      Repository allUsersRepo, List<Change.Id> virtualIds, Account.Id caller) {
     List<String> starRefs =
-        changeIds.stream()
+        virtualIds.stream()
             .map(c -> RefNames.refsStarredChanges(c, caller))
             .collect(Collectors.toList());
     try {
@@ -140,21 +140,21 @@
     } catch (IOException e) {
       logger.atWarning().withCause(e).log(
           "Failed getting starred changes for account %d within changes: %s",
-          caller.get(), Joiner.on(", ").join(changeIds));
+          caller.get(), Joiner.on(", ").join(virtualIds));
       return ImmutableSet.of();
     }
   }
 
   @Override
-  public void unstarAllForChangeDeletion(Change.Id changeId) throws IOException {
+  public void unstarAllForChangeDeletion(Change.Id virtualId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
       BatchRefUpdate batchUpdate = repo.getRefDatabase().newBatchUpdate();
       batchUpdate.setAllowNonFastForwards(true);
       batchUpdate.setRefLogIdent(serverIdent.get());
-      batchUpdate.setRefLogMessage("Unstar change " + changeId.get(), true);
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        String refName = RefNames.refsStarredChanges(changeId, accountId);
+      batchUpdate.setRefLogMessage("Unstar change " + virtualId.get(), true);
+      for (Account.Id accountId : getStars(repo, virtualId)) {
+        String refName = RefNames.refsStarredChanges(virtualId, accountId);
         Ref ref = repo.getRefDatabase().exactRef(refName);
         if (ref != null) {
           batchUpdate.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), refName));
@@ -166,7 +166,7 @@
           String message =
               String.format(
                   "Unstar change %d failed, ref %s could not be deleted: %s",
-                  changeId.get(), command.getRefName(), command.getResult());
+                  virtualId.get(), command.getRefName(), command.getResult());
           if (command.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
             throw new LockFailureException(message, batchUpdate);
           }
@@ -177,11 +177,11 @@
   }
 
   @Override
-  public ImmutableList<Account.Id> byChange(Change.Id changeId) {
+  public ImmutableList<Account.Id> byChange(Change.Id virtualId) {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       ImmutableList.Builder<Account.Id> builder = ImmutableList.builder();
-      for (Account.Id accountId : getStars(repo, changeId)) {
-        Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(changeId, accountId));
+      for (Account.Id accountId : getStars(repo, virtualId)) {
+        Optional<Ref> starRef = getStarRef(repo, RefNames.refsStarredChanges(virtualId, accountId));
         if (starRef.isPresent()) {
           builder.add(accountId);
         }
@@ -189,7 +189,7 @@
       return builder.build();
     } catch (IOException e) {
       throw new StorageException(
-          String.format("Get accounts that starred change %d failed", changeId.get()), e);
+          String.format("Get accounts that starred change %d failed", virtualId.get()), e);
     }
   }
 
@@ -223,9 +223,9 @@
     }
   }
 
-  private static Set<Account.Id> getStars(Repository allUsers, Change.Id changeId)
+  private static Set<Account.Id> getStars(Repository allUsers, Change.Id virtualId)
       throws IOException {
-    String prefix = RefNames.refsStarredChangesPrefix(changeId);
+    String prefix = RefNames.refsStarredChangesPrefix(virtualId);
     RefDatabase refDb = allUsers.getRefDatabase();
     return refDb.getRefsByPrefix(prefix).stream()
         .map(r -> r.getName().substring(prefix.length()))
diff --git a/java/com/google/gerrit/server/plugins/DisablePlugin.java b/java/com/google/gerrit/server/plugins/DisablePlugin.java
index 9e238f8..d845ec17 100644
--- a/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -53,7 +53,12 @@
     if (mandatoryPluginsCollection.contains(name)) {
       throw new MethodNotAllowedException("Plugin " + name + " is mandatory");
     }
-    loader.disablePlugins(ImmutableSet.of(name));
+    try {
+      loader.disablePlugins(ImmutableSet.of(name));
+    } catch (PluginInstallException e) {
+      throw new MethodNotAllowedException("Plugin " + name + " cannot be disabled", e);
+    }
+
     return Response.ok(ListPlugins.toPluginInfo(loader.get(name)));
   }
 }
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 9e23c0b..e122d8d 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -221,7 +221,7 @@
     toCleanup.add(plugin);
   }
 
-  public void disablePlugins(Set<String> names) {
+  public void disablePlugins(Set<String> names) throws PluginInstallException {
     if (!isRemoteAdminEnabled()) {
       logger.atWarning().log(
           "Remote plugin administration is disabled, ignoring disablePlugins(%s)", names);
@@ -235,6 +235,12 @@
           continue;
         }
 
+        if (active.getApiModule().isPresent()) {
+          throw new PluginInstallException(
+              String.format(
+                  "Plugin %s has registered an ApiModule therefore it cannot be disabled", name));
+        }
+
         if (mandatoryPlugins.contains(name)) {
           logger.atWarning().log("Mandatory plugin %s cannot be disabled", name);
           continue;
@@ -381,11 +387,14 @@
         try {
           logger.atInfo().log("Reloading plugin %s", name);
           Plugin newPlugin = runPlugin(name, active.getSrcFile(), active);
-          logger.atInfo().log(
-              "Reloaded plugin %s%s, version %s",
-              newPlugin.getName(),
-              newPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
-              newPlugin.getVersion());
+
+          if (newPlugin != active) {
+            logger.atInfo().log(
+                "Reloaded plugin %s%s, version %s",
+                newPlugin.getName(),
+                newPlugin.getApiModule().isPresent() ? " (w/ ApiModule)" : "",
+                newPlugin.getVersion());
+          }
         } catch (PluginInstallException e) {
           logger.atWarning().withCause(e.getCause()).log("Cannot reload plugin %s", name);
           throw e;
@@ -522,6 +531,13 @@
         return oldPlugin;
       }
 
+      if (oldPlugin != null && oldPlugin.getApiModule().isPresent()) {
+        throw new PluginInstallException(
+            String.format(
+                "Plugin %s has registered an ApiModule therefore its restart/reload is not allowed",
+                name));
+      }
+
       Plugin newPlugin = loadPlugin(name, plugin, snapshot);
       if (newPlugin.getCleanupHandle() != null) {
         cleanupHandles.put(newPlugin, newPlugin.getCleanupHandle());
@@ -573,7 +589,13 @@
       }
     }
     for (String name : unload) {
-      unloadPlugin(running.get(name));
+      Plugin runningPlugin = running.get(name);
+
+      if (runningPlugin.getApiModule().isPresent()) {
+        logger.atWarning().log("Cannot remove plugin %s as it has registered an ApiModule", name);
+      } else {
+        unloadPlugin(running.get(name));
+      }
     }
   }
 
diff --git a/java/com/google/gerrit/server/plugins/ServerPlugin.java b/java/com/google/gerrit/server/plugins/ServerPlugin.java
index c275ff9..036285e 100644
--- a/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -172,6 +172,11 @@
   @Override
   protected boolean canReload() {
     Attributes main = manifest.getMainAttributes();
+    String apiModule = main.getValue("Gerrit-ApiModule");
+    if (apiModule != null) {
+      return false;
+    }
+
     String v = main.getValue("Gerrit-ReloadMode");
     if (Strings.isNullOrEmpty(v) || "reload".equalsIgnoreCase(v)) {
       return true;
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 5676ab4..d153bc9 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -50,7 +50,6 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
@@ -78,7 +77,6 @@
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.SkipCurrentRulesEvaluationOnClosedChanges;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -110,6 +108,7 @@
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
@@ -248,11 +247,11 @@
     }
 
     public ChangeData create(Project.NameKey project, Change.Id id) {
-      return assistedFactory.create(project, id, null, null);
+      return assistedFactory.create(project, id, null, null, null);
     }
 
     public ChangeData create(Project.NameKey project, Change.Id id, ObjectId metaRevision) {
-      ChangeData cd = assistedFactory.create(project, id, null, null);
+      ChangeData cd = assistedFactory.create(project, id, null, null, null);
       cd.setMetaRevision(metaRevision);
       return cd;
     }
@@ -265,19 +264,29 @@
     }
 
     public ChangeData create(Change change) {
-      return assistedFactory.create(change.getProject(), change.getId(), change, null);
+      return create(change, null);
+    }
+
+    public ChangeData create(Change change, Change.Id virtualId) {
+      return assistedFactory.create(
+          change.getProject(),
+          change.getId(),
+          !Objects.equals(virtualId, change.getId()) ? virtualId : null,
+          change,
+          null);
     }
 
     public ChangeData create(ChangeNotes notes) {
       return assistedFactory.create(
-          notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
+          notes.getChange().getProject(), notes.getChangeId(), null, notes.getChange(), notes);
     }
   }
 
   public interface AssistedFactory {
     ChangeData create(
         Project.NameKey project,
-        Change.Id id,
+        @Assisted("changeId") Change.Id id,
+        @Assisted("virtualId") @Nullable Change.Id virtualId,
         @Nullable Change change,
         @Nullable ChangeNotes notes);
   }
@@ -296,7 +305,7 @@
    */
   public static ChangeData createForTest(
       Project.NameKey project, Change.Id id, int currentPatchSetId, ObjectId commitId) {
-    return createForTest(project, id, currentPatchSetId, commitId, null, null, null);
+    return createForTest(project, id, currentPatchSetId, commitId, null, null);
   }
 
   /**
@@ -309,7 +318,6 @@
    * @param id change ID
    * @param currentPatchSetId current patchset number
    * @param commitId commit SHA1 of the current patchset
-   * @param serverId Gerrit server id
    * @param virtualIdAlgo algorithm for virtualising the Change number
    * @param changeNotes notes associated with the Change
    * @return instance for testing.
@@ -319,7 +327,6 @@
       Change.Id id,
       int currentPatchSetId,
       ObjectId commitId,
-      @Nullable String serverId,
       @Nullable ChangeNumberVirtualIdAlgorithm virtualIdAlgo,
       @Nullable ChangeNotes changeNotes) {
     ChangeData cd =
@@ -343,12 +350,12 @@
             null,
             null,
             null,
-            serverId,
             virtualIdAlgo,
             false,
             project,
             id,
             null,
+            null,
             changeNotes);
     cd.currentPatchSet =
         PatchSet.builder()
@@ -450,12 +457,12 @@
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
   private Optional<Instant> mergedOn;
-  private ImmutableSetMultimap<NameKey, RefState> refStates;
+  private ImmutableSetMultimap<Project.NameKey, RefState> refStates;
   private ImmutableList<byte[]> refStatePatterns;
-  private String gerritServerId;
   private String changeServerId;
   private ChangeNumberVirtualIdAlgorithm virtualIdFunc;
   private Boolean failedParsingFromIndex = false;
+  private Change.Id virtualId;
 
   @Inject
   private ChangeData(
@@ -478,11 +485,11 @@
       SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRequirementsUtil submitRequirementsUtil,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
-      @GerritServerId String gerritServerId,
       ChangeNumberVirtualIdAlgorithm virtualIdFunc,
       @SkipCurrentRulesEvaluationOnClosedChanges Boolean skipCurrentRulesEvaluationOnClosedChange,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id,
+      @Assisted("changeId") Change.Id id,
+      @Assisted("virtualId") @Nullable Change.Id virtualId,
       @Assisted @Nullable Change change,
       @Assisted @Nullable ChangeNotes notes) {
     this.approvalsUtil = approvalsUtil;
@@ -516,8 +523,8 @@
     this.notes = notes;
 
     this.changeServerId = notes == null ? null : notes.getServerId();
-    this.gerritServerId = gerritServerId;
     this.virtualIdFunc = virtualIdFunc;
+    this.virtualId = virtualId;
   }
 
   /**
@@ -648,12 +655,33 @@
     return legacyId;
   }
 
-  public Change.Id getVirtualId() {
-    if (virtualIdFunc == null || changeServerId == null || changeServerId.equals(gerritServerId)) {
-      return legacyId;
+  public static void ensureChangeServerId(Iterable<ChangeData> changes) {
+    ChangeData first = Iterables.getFirst(changes, null);
+    if (first == null) {
+      return;
     }
 
-    return Change.id(virtualIdFunc.apply(changeServerId, legacyId.get()));
+    for (ChangeData cd : changes) {
+      var unused = cd.changeServerId();
+    }
+  }
+
+  @Nullable
+  public String changeServerId() {
+    if (changeServerId == null) {
+      if (!lazyload()) {
+        return null;
+      }
+      changeServerId = notes().getServerId();
+    }
+    return changeServerId;
+  }
+
+  public Change.Id virtualId() {
+    if (virtualId == null) {
+      return virtualIdFunc == null ? legacyId : virtualIdFunc.apply(changeServerId, legacyId);
+    }
+    return virtualId;
   }
 
   public Project.NameKey project() {
@@ -692,10 +720,10 @@
     return this;
   }
 
-  public ObjectId metaRevisionOrThrow() {
+  public Optional<ObjectId> metaRevision() {
     if (notes == null) {
       if (metaRevision != null) {
-        return metaRevision;
+        return Optional.of(metaRevision);
       }
       if (refStates != null) {
         ImmutableSet<RefState> refs = refStates.get(project);
@@ -703,17 +731,25 @@
           String metaRef = RefNames.changeMetaRef(getId());
           for (RefState r : refs) {
             if (r.ref().equals(metaRef)) {
-              return r.id();
+              return Optional.of(r.id());
             }
           }
         }
       }
-      throwIfNotLazyLoad("metaRevision");
+      if (!lazyload()) {
+        return Optional.empty();
+      }
 
       @SuppressWarnings("unused")
       var unused = notes();
     }
-    return notes.getRevision();
+    metaRevision = notes.getRevision();
+    return Optional.of(metaRevision);
+  }
+
+  public ObjectId metaRevisionOrThrow() {
+    return metaRevision()
+        .orElseThrow(() -> new IllegalStateException("'metaRevision' field not populated"));
   }
 
   boolean fastIsVisibleTo(CurrentUser user) {
@@ -1436,7 +1472,7 @@
       if (!lazyload()) {
         return ImmutableList.of();
       }
-      starAccounts = requireNonNull(starredChangesReader).byChange(legacyId);
+      starAccounts = requireNonNull(starredChangesReader).byChange(virtualId());
     }
     return starAccounts;
   }
@@ -1499,13 +1535,14 @@
     }
   }
 
-  public SetMultimap<NameKey, RefState> getRefStates() {
+  public SetMultimap<Project.NameKey, RefState> getRefStates() {
     if (refStates == null) {
       if (!lazyload()) {
         return ImmutableSetMultimap.of();
       }
 
-      ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
+      ImmutableSetMultimap.Builder<Project.NameKey, RefState> result =
+          ImmutableSetMultimap.builder();
       for (Table.Cell<Account.Id, PatchSet.Id, Ref> edit : editRefs().cellSet()) {
         result.put(
             project,
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
index 726a376..95c287a 100644
--- a/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberBitmapMaskAlgorithm.java
@@ -16,7 +16,9 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.server.config.GerritImportedServerIds;
+import com.google.gerrit.server.config.GerritServerId;
 import com.google.inject.Inject;
 import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
@@ -38,9 +40,11 @@
       Integer.BYTES * 8 - CHANGE_NUM_BIT_LEN; // Allows up to 64 ServerIds
 
   private final ImmutableMap<String, Integer> serverIdCodes;
+  private final String localServerId;
 
   @Inject
   public ChangeNumberBitmapMaskAlgorithm(
+      @GerritServerId String localServerId,
       @GerritImportedServerIds ImmutableList<String> importedServerIds) {
     if (importedServerIds.size() >= 1 << SERVER_ID_BIT_LEN) {
       throw new ProvisionException(
@@ -54,10 +58,17 @@
     }
 
     serverIdCodes = serverIdCodesBuilder.build();
+    this.localServerId = localServerId;
   }
 
   @Override
-  public int apply(String changeServerId, int changeNum) {
+  public Change.Id apply(String changeServerId, Change.Id changeNumId) {
+    if (changeServerId == null || localServerId.equals(changeServerId)) {
+      return changeNumId;
+    }
+
+    int changeNum = changeNumId.get();
+
     if ((changeNum & LEGACY_ID_BIT_MASK) != changeNum) {
       throw new IllegalArgumentException(
           String.format(
@@ -71,6 +82,6 @@
     }
     int virtualId = (changeNum & LEGACY_ID_BIT_MASK) | (encodedServerId << CHANGE_NUM_BIT_LEN);
 
-    return virtualId;
+    return Change.id(virtualId);
   }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
index ab21705..6daf16f 100644
--- a/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
+++ b/java/com/google/gerrit/server/query/change/ChangeNumberVirtualIdAlgorithm.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.entities.Change;
 import com.google.inject.ImplementedBy;
 
 /**
@@ -31,5 +32,5 @@
    * @param legacyChangeNum legacy change number
    * @return virtual id which combines serverId and legacyChangeNum together
    */
-  int apply(String serverId, int legacyChangeNum);
+  Change.Id apply(String serverId, Change.Id legacyChangeNum);
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
index 587f2de..d5f9c5b 100644
--- a/java/com/google/gerrit/server/query/change/ChangePredicates.java
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -87,7 +88,10 @@
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has a pending draft comment.
+   *
+   * <p>The predicates filter by "legacy_id_str" field.
    */
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static Predicate<ChangeData> draftBy(
       DraftCommentsReader draftCommentsReader, Account.Id id) {
     ImmutableSet<Predicate<ChangeData>> changeIdPredicates =
@@ -102,7 +106,10 @@
   /**
    * Returns a predicate that matches changes where the provided {@link
    * com.google.gerrit.entities.Account.Id} has starred changes with {@code label}.
+   *
+   * <p>The predicates filter by "legacy_id_str" field.
    */
+  @UsedAt(UsedAt.Project.GOOGLE)
   public static Predicate<ChangeData> starBy(
       StarredChangesReader starredChangesReader, Account.Id id) {
     ImmutableSet<Predicate<ChangeData>> starredChanges =
@@ -144,6 +151,19 @@
   }
 
   /**
+   * Returns a predicate that matches the change number with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> changeNumber(
+      Change.Id id, ChangeQueryBuilder.Arguments args) {
+    if (args.getSchema().hasField(ChangeField.CHANGENUM_SPEC)) {
+      return new ChangeIndexCardinalPredicate(
+          ChangeField.CHANGENUM_SPEC, ChangeQueryBuilder.FIELD_CHANGE, id.toString(), 1);
+    }
+    return idStr(id);
+  }
+
+  /**
    * Returns a predicate that matches changes owned by the provided {@link
    * com.google.gerrit.entities.Account.Id}.
    */
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index a64b68d..d598739 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -144,7 +144,7 @@
 
   public interface ChangeIsOperandFactory extends ChangeOperandFactory {}
 
-  private static final Pattern PAT_LEGACY_ID = Pattern.compile("^[1-9][0-9]*$");
+  private static final Pattern PAT_CHANGE_NUMBER = Pattern.compile("^[1-9][0-9]*$");
   private static final Pattern PAT_PROJECT_CHANGE_NUM = Pattern.compile("^([^~]+)~([1-9][0-9]*)$");
   private static final Pattern PAT_CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
   private static final Pattern DEF_CHANGE =
@@ -166,6 +166,7 @@
 
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
+  public static final String FIELD_CHANGE_NUMBER = "changenumber";
   public static final String FIELD_COMMENT = "comment";
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
@@ -588,6 +589,18 @@
 
   @Operator
   public Predicate<ChangeData> change(String query) throws QueryParseException {
+    return getPredicateChangeData(query, changeId -> ChangePredicates.changeNumber(changeId, args));
+  }
+
+  // Keep using the index legacy document-id (legacy_id_str) for URLs queries like: "/q/123456",
+  // "/q/Iasdw2312321", "/q/project~123456"  that are expecting to always find a single element.
+  private Predicate<ChangeData> defaultSearch(String query) throws QueryParseException {
+    return getPredicateChangeData(query, ChangePredicates::idStr);
+  }
+
+  private Predicate<ChangeData> getPredicateChangeData(
+      String query, Function<Change.Id, Predicate<ChangeData>> changePredicateGetter)
+      throws QueryParseException {
     Optional<ChangeTriplet> triplet = ChangeTriplet.parse(query);
     if (triplet.isPresent()) {
       return Predicate.and(
@@ -601,11 +614,10 @@
       return Predicate.and(
           project(projectChangeNumber.group(1)),
           ChangePredicates.idStr(projectChangeNumber.group(2)));
-
-    } else if (PAT_LEGACY_ID.matcher(query).matches()) {
+    } else if (PAT_CHANGE_NUMBER.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
-        return ChangePredicates.idStr(Change.id(id));
+        return changePredicateGetter.apply(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
       return ChangePredicates.idPrefix(parseChangeId(query));
@@ -1690,13 +1702,13 @@
     } else if (DEF_CHANGE.matcher(query).matches()) {
       List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(2);
       try {
-        predicates.add(change(query));
+        predicates.add(defaultSearch(query));
       } catch (QueryParseException e) {
         // Skip.
       }
 
-      // For PAT_LEGACY_ID, it may also be the prefix of some commits.
-      if (query.length() >= 6 && PAT_LEGACY_ID.matcher(query).matches()) {
+      // For PAT_CHANGE_NUMBER, it may also be the prefix of some commits.
+      if (query.length() >= 6 && PAT_CHANGE_NUMBER.matcher(query).matches()) {
         predicates.add(commit(query));
       }
 
@@ -1860,12 +1872,12 @@
   }
 
   private List<ChangeData> parseChangeData(String value) throws QueryParseException {
-    if (PAT_LEGACY_ID.matcher(value).matches()) {
+    if (PAT_CHANGE_NUMBER.matcher(value).matches()) {
       Optional<Change.Id> id = Change.Id.tryParse(value);
       if (!id.isPresent()) {
         throw error("Invalid change id " + value);
       }
-      return args.queryProvider.get().byLegacyChangeId(id.get());
+      return args.queryProvider.get().byChangeNumber(id.get());
     } else if (PAT_CHANGE_ID.matcher(value).matches()) {
       List<ChangeData> changes = args.queryProvider.get().byKeyPrefix(parseChangeId(value));
       if (changes.isEmpty()) {
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index fc4c1d0..87b8915 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -89,7 +89,7 @@
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
     and.add(ChangePredicates.project(c.getProject()));
     and.add(ChangePredicates.ref(c.getDest().branch()));
-    and.add(Predicate.not(ChangePredicates.idStr(c.getId())));
+    and.add(Predicate.not(ChangePredicates.changeNumber(c.getId(), args)));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index f3eda15..a8ee4bc 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -88,6 +88,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory notesFactory;
   private final EditByPredicateProvider editByPredicateProvider;
+  private final Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider;
 
   @Inject
   InternalChangeQuery(
@@ -96,11 +97,13 @@
       IndexConfig indexConfig,
       ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory notesFactory,
-      EditByPredicateProvider editByPredicateProvider) {
+      EditByPredicateProvider editByPredicateProvider,
+      Provider<ChangeQueryBuilder.Arguments> queryBuilderArgsProvider) {
     super(queryProcessor, indexes, indexConfig);
     this.changeDataFactory = changeDataFactory;
     this.notesFactory = notesFactory;
     this.editByPredicateProvider = editByPredicateProvider;
+    this.queryBuilderArgsProvider = queryBuilderArgsProvider;
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -115,6 +118,10 @@
     return query(ChangePredicates.idStr(id));
   }
 
+  public List<ChangeData> byChangeNumber(Change.Id id) {
+    return query(ChangePredicates.changeNumber(id, queryBuilderArgsProvider.get()));
+  }
+
   @UsedAt(UsedAt.Project.GOOGLE)
   public List<ChangeData> byLegacyChangeIds(Collection<Change.Id> ids) {
     List<Predicate<ChangeData>> preds = new ArrayList<>(ids.size());
@@ -319,7 +326,7 @@
     for (List<String> part : Iterables.partition(groups, batchSize)) {
       for (ChangeData cd :
           queryExhaustively(querySupplier, byProjectGroupsPredicate(indexConfig, project, part))) {
-        if (!seen.add(cd.getId())) {
+        if (!seen.add(cd.virtualId())) {
           result.add(cd);
         }
       }
diff --git a/java/com/google/gerrit/server/query/change/OrSource.java b/java/com/google/gerrit/server/query/change/OrSource.java
index 9633a18..8894287 100644
--- a/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/java/com/google/gerrit/server/query/change/OrSource.java
@@ -52,7 +52,7 @@
           Set<Change.Id> have = new HashSet<>();
           for (ResultSet<ChangeData> resultSet : results) {
             for (ChangeData result : resultSet) {
-              if (have.add(result.getId())) {
+              if (have.add(result.virtualId())) {
                 r.add(result);
               }
             }
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index ebe4390..02d2ca6 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -71,7 +71,7 @@
    *
    * @param args arguments to be parsed
    */
-  PredicateArgs(String args) throws QueryParseException {
+  public PredicateArgs(String args) throws QueryParseException {
     positional = new ArrayList<>();
     keyValue = new HashMap<>();
 
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index 1565aba..95aa07c 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -69,7 +69,7 @@
       throws RestApiException, PermissionBackendException, IOException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
-    if (starredChangesReader.isStarred(user.getAccountId(), change.getId())) {
+    if (starredChangesReader.isStarred(user.getAccountId(), change.getVirtualId())) {
       return new AccountResource.StarredChange(user, change);
     }
     throw new ResourceNotFoundException(id);
@@ -125,7 +125,7 @@
       }
 
       try {
-        starredChangesWriter.star(self.get().getAccountId(), change.getId());
+        starredChangesWriter.star(self.get().getAccountId(), change.getVirtualId());
       } catch (DuplicateKeyException e) {
         return Response.none();
       }
@@ -168,7 +168,7 @@
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
-      starredChangesWriter.unstar(self.get().getAccountId(), rsrc.getChange().getId());
+      starredChangesWriter.unstar(self.get().getAccountId(), rsrc.getVirtualId());
       return Response.none();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index 341a9d9..e254bfc 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -26,15 +26,18 @@
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
 import com.google.gerrit.exceptions.InvalidMergeStrategyException;
 import com.google.gerrit.exceptions.MergeWithConflictsNotSupportedException;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
+import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -52,6 +55,7 @@
 import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.proto.Entities;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -96,6 +100,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -118,6 +123,8 @@
 public class CreateChange
     implements RestCollectionModifyView<TopLevelResource, ChangeResource, ChangeInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final ChangeInputProtoConverter CHANGE_INPUT_PROTO_CONVERTER =
+      ChangeInputProtoConverter.INSTANCE;
 
   private final BatchUpdate.Factory updateFactory;
   private final String anonymousCowardName;
@@ -191,11 +198,51 @@
     return execute(updateFactory, input, projectsCollection.parse(input.project));
   }
 
-  /** Creates the changes in the given project. This is public for reuse in the project API. */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  @FunctionalInterface
+  public interface CommitTreeSupplier {
+    @NonNull
+    ObjectId get(Repository repo, ObjectInserter oi, ChangeInput input, RevCommit mergeTip)
+        throws IOException, RestApiException;
+  }
+
+  /**
+   * Creates the changes in the given project, using the proto representation of ChangeInput -
+   * {@link com.google.gerrit.proto.Entities.ChangeInput}.
+   */
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory,
+      Entities.ChangeInput input,
+      CommitTreeSupplier commitTreeSupplier)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
+    return execute(
+        updateFactory,
+        CHANGE_INPUT_PROTO_CONVERTER.fromProto(input),
+        projectsCollection.parse(input.getProject()),
+        Optional.of(commitTreeSupplier));
+  }
+
+  /**
+   * Creates the changes in the given project, using the java-class representation of ChangeInput -
+   * {@link com.google.gerrit.extensions.common.ChangeInput}. This is public for reuse in the
+   * project API.
+   */
   public Response<ChangeInfo> execute(
       BatchUpdate.Factory updateFactory, ChangeInput input, ProjectResource projectResource)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
+    return execute(updateFactory, input, projectResource, Optional.empty());
+  }
+
+  private Response<ChangeInfo> execute(
+      BatchUpdate.Factory updateFactory,
+      ChangeInput input,
+      ProjectResource projectResource,
+      Optional<CommitTreeSupplier> commitTreeSupplier)
+      throws IOException, RestApiException, UpdateException, PermissionBackendException,
+          ConfigInvalidException {
     if (!user.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
@@ -204,14 +251,15 @@
     projectState.checkStatePermitsWrite();
 
     IdentifiedUser me = user.get().asIdentifiedUser();
-    checkAndSanitizeChangeInput(input, me);
+    checkAndSanitizeChangeInput(input, me, commitTreeSupplier);
 
     Project.NameKey project = projectResource.getNameKey();
     contributorAgreements.check(project, user.get());
 
     checkRequiredPermissions(project, input.branch, input.author);
 
-    ChangeInfo newChange = createNewChange(input, me, projectState, updateFactory);
+    ChangeInfo newChange =
+        createNewChange(input, me, projectState, updateFactory, commitTreeSupplier);
     return Response.created(newChange);
   }
 
@@ -225,7 +273,8 @@
    * @param me the user who sent the current request to create a change.
    * @throws BadRequestException if the input is not legal.
    */
-  private void checkAndSanitizeChangeInput(ChangeInput input, IdentifiedUser me)
+  private void checkAndSanitizeChangeInput(
+      ChangeInput input, IdentifiedUser me, Optional<CommitTreeSupplier> commitTreeSupplier)
       throws RestApiException, PermissionBackendException, IOException {
     if (Strings.isNullOrEmpty(input.branch)) {
       throw new BadRequestException("branch must be non-empty");
@@ -303,6 +352,11 @@
       throw new BadRequestException("Only one of `merge` and `patch` arguments can be set.");
     }
 
+    if ((input.merge != null || input.patch != null) && commitTreeSupplier.isPresent()) {
+      throw new BadRequestException(
+          "`CommitTreeSupplier` cannot be provided along with `merge` or `patch` arguments");
+    }
+
     if (input.author != null
         && (Strings.isNullOrEmpty(input.author.email)
             || Strings.isNullOrEmpty(input.author.name))) {
@@ -332,7 +386,8 @@
       ChangeInput input,
       IdentifiedUser me,
       ProjectState projectState,
-      BatchUpdate.Factory updateFactory)
+      BatchUpdate.Factory updateFactory,
+      Optional<CommitTreeSupplier> commitTreeSupplier)
       throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
           UpdateException {
     try (RefUpdateContext ctx = RefUpdateContext.open(CHANGE_MODIFICATION)) {
@@ -404,31 +459,22 @@
           }
         } else if (input.patch != null) {
           // create a commit with the given patch.
-          if (mergeTip == null) {
-            throw new BadRequestException("Cannot apply patch on top of an empty tree.");
-          }
-          PatchApplier.Result applyResult =
-              ApplyPatchUtil.applyPatch(git, oi, input.patch, mergeTip);
-          ObjectId treeId = applyResult.getTreeId();
-          logger.atFine().log("tree ID after applying patch: %s", treeId.name());
-          String appliedPatchCommitMessage =
-              getCommitMessage(
-                  ApplyPatchUtil.buildCommitMessage(
-                      input.subject,
-                      ImmutableList.of(),
-                      input.patch.patch,
-                      ApplyPatchUtil.getResultPatch(git, reader, mergeTip, rw.lookupTree(treeId)),
-                      applyResult.getErrors()),
-                  me);
           c =
-              rw.parseCommit(
-                  CommitUtil.createCommitWithTree(
-                      oi,
-                      author,
-                      committer,
-                      ImmutableList.of(mergeTip),
-                      appliedPatchCommitMessage,
-                      treeId));
+              createCommitWithPatch(
+                  git, reader, oi, rw, mergeTip, input.patch, input.subject, author, committer, me);
+        } else if (commitTreeSupplier.isPresent()) {
+          c =
+              createCommitWithSuppliedTree(
+                  git,
+                  oi,
+                  rw,
+                  mergeTip,
+                  input,
+                  commitTreeSupplier.get(),
+                  author,
+                  committer,
+                  commitMessage);
+
         } else {
           // create an empty commit.
           c = createEmptyCommit(oi, rw, author, committer, mergeTip, commitMessage);
@@ -615,6 +661,63 @@
             oi, authorIdent, committerIdent, parents, commitMessage, treeId));
   }
 
+  private CodeReviewCommit createCommitWithPatch(
+      Repository repo,
+      ObjectReader reader,
+      ObjectInserter oi,
+      CodeReviewRevWalk rw,
+      RevCommit mergeTip,
+      ApplyPatchInput patch,
+      String subject,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      IdentifiedUser me)
+      throws IOException, RestApiException {
+    if (mergeTip == null) {
+      throw new BadRequestException("Cannot apply patch on top of an empty tree.");
+    }
+    PatchApplier.Result applyResult = ApplyPatchUtil.applyPatch(repo, oi, patch, mergeTip);
+    ObjectId treeId = applyResult.getTreeId();
+    logger.atFine().log("tree ID after applying patch: %s", treeId.name());
+    String appliedPatchCommitMessage =
+        getCommitMessage(
+            ApplyPatchUtil.buildCommitMessage(
+                subject,
+                ImmutableList.of(),
+                patch.patch,
+                ApplyPatchUtil.getResultPatch(repo, reader, mergeTip, rw.lookupTree(treeId)),
+                applyResult.getErrors()),
+            me);
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi,
+            authorIdent,
+            committerIdent,
+            ImmutableList.of(mergeTip),
+            appliedPatchCommitMessage,
+            treeId));
+  }
+
+  private static CodeReviewCommit createCommitWithSuppliedTree(
+      Repository repo,
+      ObjectInserter oi,
+      CodeReviewRevWalk rw,
+      RevCommit mergeTip,
+      ChangeInput input,
+      CommitTreeSupplier commitTreeSupplier,
+      PersonIdent authorIdent,
+      PersonIdent committerIdent,
+      String commitMessage)
+      throws IOException, RestApiException {
+    if (mergeTip == null) {
+      throw new BadRequestException("`CommitTreeSupplier` cannot be used on top of an empty tree.");
+    }
+    ObjectId treeId = commitTreeSupplier.get(repo, oi, input, mergeTip);
+    return rw.parseCommit(
+        CommitUtil.createCommitWithTree(
+            oi, authorIdent, committerIdent, ImmutableList.of(mergeTip), commitMessage, treeId));
+  }
+
   private static ObjectId emptyTreeId(ObjectInserter inserter) throws IOException {
     return inserter.insert(new TreeFormatter());
   }
diff --git a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
index 0119349b..d478a9d 100644
--- a/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
+++ b/java/com/google/gerrit/sshd/commands/PluginRemoveCommand.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
 import com.google.common.collect.Sets;
+import com.google.gerrit.server.plugins.PluginInstallException;
 import com.google.gerrit.sshd.CommandMetaData;
 import java.util.List;
 import org.kohsuke.args4j.Argument;
@@ -29,7 +30,11 @@
   @Override
   protected void doRun() throws UnloggedFailure {
     if (names != null && !names.isEmpty()) {
-      loader.disablePlugins(Sets.newHashSet(names));
+      try {
+        loader.disablePlugins(Sets.newHashSet(names));
+      } catch (PluginInstallException e) {
+        throw die(e);
+      }
     }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index dcdbce3..698eac8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.converter.ChangeInputProtoConverter;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.changes.ApplyPatchInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -82,7 +83,11 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.restapi.change.ApplyPatchUtil;
+import com.google.gerrit.server.restapi.change.CreateChange;
+import com.google.gerrit.server.restapi.change.CreateChange.CommitTreeSupplier;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
@@ -110,9 +115,13 @@
 
 @UseClockStep
 public class CreateChangeIT extends AbstractDaemonTest {
+  private static final ChangeInputProtoConverter CHANGE_INPUT_PROTO_CONVERTER =
+      ChangeInputProtoConverter.INSTANCE;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private CreateChange createChangeImpl;
+  @Inject private BatchUpdate.Factory updateFactory;;
 
   @Before
   public void addNonCommitHead() throws Exception {
@@ -1157,6 +1166,26 @@
   }
 
   @Test
+  public void createChangeWithCommitTreeSupplier_success() throws Exception {
+    createBranch(BranchNameKey.create(project, "other"));
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.branch = "other";
+    input.subject = "custom commit message";
+    ApplyPatchInput applyPatchInput = new ApplyPatchInput();
+    applyPatchInput.patch = PATCH_INPUT;
+    CommitTreeSupplier commitTreeSupplier =
+        (repo, oi, in, mergeTip) ->
+            ApplyPatchUtil.applyPatch(repo, oi, applyPatchInput, mergeTip).getTreeId();
+
+    ChangeInfo info = assertCreateWithCommitTreeSupplierSucceeds(input, commitTreeSupplier);
+
+    DiffInfo diff = gApi.changes().id(info.id).current().file(PATCH_FILE_NAME).diff();
+    assertDiffForNewFile(diff, info.currentRevision, PATCH_FILE_NAME, PATCH_NEW_FILE_CONTENT);
+    assertThat(info.revisions.get(info.currentRevision).commit.message)
+        .isEqualTo("custom commit message\n\nChange-Id: " + info.changeId + "\n");
+  }
+
+  @Test
   @UseSystemTime
   public void sha1sOfTwoNewChangesDiffer() throws Exception {
     ChangeInput changeInput = newChangeInput(ChangeStatus.NEW);
@@ -1351,6 +1380,18 @@
     return out;
   }
 
+  private ChangeInfo assertCreateWithCommitTreeSupplierSucceeds(
+      ChangeInput input, CommitTreeSupplier commitTreeSupplier) throws Exception {
+    ChangeInfo res =
+        createChangeImpl
+            .execute(updateFactory, CHANGE_INPUT_PROTO_CONVERTER.toProto(input), commitTreeSupplier)
+            .value();
+    // The original result doesn't contain any revision data.
+    ChangeInfo out = gApi.changes().id(res.changeId).get(ALL_REVISIONS, CURRENT_COMMIT);
+    validateCreateSucceeds(input, out);
+    return out;
+  }
+
   private static <T> T readContentFromJson(RestResponse r, Class<T> clazz) throws Exception {
     try (JsonReader jsonReader = new JsonReader(r.getReader())) {
       return newGson().fromJson(jsonReader, clazz);
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
index 84c3936..c4497dc 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshTraceIT.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
+import java.time.Instant;
 import org.junit.Test;
 
 @UseSsh
@@ -123,8 +124,8 @@
     private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
-      logEntries.add(PerformanceLogEntry.create(operation, metadata));
+    public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
+      logEntries.add(PerformanceLogEntry.create(operation, endTime, metadata));
     }
 
     ImmutableList<PerformanceLogEntry> logEntries() {
@@ -134,12 +135,14 @@
 
   @AutoValue
   abstract static class PerformanceLogEntry {
-    static PerformanceLogEntry create(String operation, Metadata metadata) {
-      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, metadata);
+    static PerformanceLogEntry create(String operation, Instant endTime, Metadata metadata) {
+      return new AutoValue_SshTraceIT_PerformanceLogEntry(operation, endTime, metadata);
     }
 
     abstract String operation();
 
+    abstract Instant endTime();
+
     abstract Metadata metadata();
   }
 }
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index bbf10bd..812a0df 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -32,6 +32,7 @@
 
 public class ChangeProtoConverterTest {
   private final ChangeProtoConverter changeProtoConverter = ChangeProtoConverter.INSTANCE;
+  private static final String TEST_SERVER_ID = "test-server-id";
 
   @Test
   public void allValuesConvertedToProto() {
@@ -42,6 +43,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch 74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
@@ -89,6 +91,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
 
@@ -124,6 +127,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     // O as ID actually means that no current patch set is present.
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 0), null, null);
 
@@ -161,6 +165,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     change.setCurrentPatchSet(PatchSet.id(Change.id(14), 23), "subject ABC", null);
 
     Entities.Change proto = changeProtoConverter.toProto(change);
@@ -189,7 +194,7 @@
   }
 
   @Test
-  public void allValuesConvertedToProtoAndBackAgain() {
+  public void allValuesConvertedToProtoAndBackAgainExceptServerId() {
     Change change =
         new Change(
             Change.key("change 1"),
@@ -197,6 +202,7 @@
             Account.id(35),
             BranchNameKey.create(Project.nameKey("project 67"), "branch-74"),
             Instant.ofEpochMilli(987654L));
+    change.setServerId(TEST_SERVER_ID);
     change.setLastUpdatedOn(Instant.ofEpochMilli(1234567L));
     change.setStatus(Change.Status.MERGED);
     change.setCurrentPatchSet(
@@ -209,6 +215,11 @@
     change.setRevertOf(Change.id(180));
 
     Change convertedChange = changeProtoConverter.fromProto(changeProtoConverter.toProto(change));
+
+    // Change serverId is not one of the protobuf definitions, hence is not supposed to be converted
+    // from proto
+    assertThat(convertedChange.getServerId()).isNull();
+    change.setServerId(null);
     assertEqualChange(convertedChange, change);
   }
 
@@ -275,6 +286,7 @@
         .hasFields(
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
+                .put("serverId", String.class)
                 .put("changeKey", Change.Key.class)
                 .put("createdOn", Instant.class)
                 .put("lastUpdatedOn", Instant.class)
@@ -298,6 +310,7 @@
   // an AutoValue.
   private static void assertEqualChange(Change change, Change expectedChange) {
     assertThat(change.getChangeId()).isEqualTo(expectedChange.getChangeId());
+    assertThat(change.getServerId()).isEqualTo(expectedChange.getServerId());
     assertThat(change.getKey()).isEqualTo(expectedChange.getKey());
     assertThat(change.getCreatedOn()).isEqualTo(expectedChange.getCreatedOn());
     assertThat(change.getLastUpdatedOn()).isEqualTo(expectedChange.getLastUpdatedOn());
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index 8cb4974..53431d1 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -158,7 +158,7 @@
   public void tolerateNullValuesForInsertion() {
     Project.NameKey project = Project.nameKey("project");
     ChangeData cd =
-        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null);
     assertThat(ChangeField.ADDED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
   }
 
@@ -166,7 +166,7 @@
   public void tolerateNullValuesForDeletion() {
     Project.NameKey project = Project.nameKey("project");
     ChangeData cd =
-        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null, null);
+        ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId(), null, null);
     assertThat(ChangeField.DELETED_LINES_SPEC.setIfPossible(cd, new FakeStoredValue(null)))
         .isTrue();
   }
diff --git a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
index 1f0da16..c1b9f13 100644
--- a/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
+++ b/javatests/com/google/gerrit/server/logging/LoggingContextAwareExecutorServiceTest.java
@@ -24,6 +24,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.time.Instant;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutorService;
@@ -51,7 +52,7 @@
     testPerformanceLogger =
         new PerformanceLogger() {
           @Override
-          public void log(String operation, long durationMs, Metadata metadata) {
+          public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
             // do nothing
           }
         };
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index 512a1b1..c93061d 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -32,6 +32,7 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import java.time.Instant;
 import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
@@ -364,7 +365,7 @@
     private ImmutableList.Builder<PerformanceLogEntry> logEntries = ImmutableList.builder();
 
     @Override
-    public void log(String operation, long durationMs, Metadata metadata) {
+    public void log(String operation, long durationMs, Instant endTime, Metadata metadata) {
       logEntries.add(PerformanceLogEntry.create(operation, metadata));
     }
 
diff --git a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
index 0ce00eb..f954a57 100644
--- a/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
+++ b/javatests/com/google/gerrit/server/query/change/ChangeDataTest.java
@@ -34,8 +34,6 @@
 
 @RunWith(MockitoJUnitRunner.class)
 public class ChangeDataTest {
-  private static final String GERRIT_SERVER_ID = UUID.randomUUID().toString();
-
   @Mock private ChangeNotes changeNotesMock;
 
   @Test
@@ -55,7 +53,7 @@
   @Test
   public void getChangeVirtualIdUsingAlgorithm() throws Exception {
     Project.NameKey project = Project.nameKey("project");
-    final int encodedChangeNum = 12345678;
+    final Change.Id encodedChangeNum = Change.id(12345678);
 
     when(changeNotesMock.getServerId()).thenReturn(UUID.randomUUID().toString());
 
@@ -65,11 +63,10 @@
             Change.id(1),
             1,
             ObjectId.zeroId(),
-            GERRIT_SERVER_ID,
             (s, c) -> encodedChangeNum,
             changeNotesMock);
 
-    assertThat(cd.getVirtualId().get()).isEqualTo(encodedChangeNum);
+    assertThat(cd.virtualId().get()).isEqualTo(encodedChangeNum.get());
   }
 
   private static PatchSet newPatchSet(Change.Id changeId, int num) {
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
index c7f4f64..d00cc45 100644
--- a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -26,6 +26,8 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -90,11 +92,49 @@
     @SuppressWarnings("unused")
     var unused = newQuery("status:new").withLimit(5).get();
 
+    // Since the limit of the query (i.e. 5) is more than the total number of changes (i.e. 4),
+    // only 1 index search is expected.
     assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
   }
 
   @Test
   @UseClockStep
+  public void queryRightNumberOfTimes() throws Exception {
+    Project.NameKey project = Project.nameKey("repo");
+    TestRepository<Repository> repo = createAndOpenProject(project);
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+
+    // create 1 visible change
+    Change visibleChange1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
+
+    // create 4 private changes
+    Change invisibleChange2 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange3 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange4 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange5 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange3.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange4.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange5.getKey().get()).setPrivate(true, null);
+
+    AbstractFakeIndex<?, ?, ?> idx =
+        (AbstractFakeIndex<?, ?, ?>) changeIndexCollection.getSearchIndex();
+    idx.resetQueryCount();
+    List<ChangeInfo> queryResult = newQuery("status:new").withLimit(2).get();
+    assertThat(queryResult).hasSize(1);
+    assertThat(queryResult.get(0).changeId).isEqualTo(visibleChange1.getKey().get());
+
+    // Since the limit of the query (i.e. 2), 2 index searches are expected in fact:
+    // 1: The first query will return invisibleChange5, invisibleChange4 and invisibleChange3,
+    // 2: Another query is needed to back-fill the limit requested by the user.
+    // even if one result in the second query is skipped because it is not visible,
+    // there are no more results to query.
+    assertThatSearchQueryWasPaginated(idx.getQueryCount(), 2);
+  }
+
+  @Test
+  @UseClockStep
   public void noLimitQueryPaginates() throws Exception {
     assumeFalse(PaginationType.NONE == getCurrentPaginationType());
 
@@ -139,39 +179,7 @@
 
   @Test
   @UseClockStep
-  public void invisibleChangesNotPaginatedWithNonePaginationType() throws Exception {
-    assumeTrue(PaginationType.NONE == getCurrentPaginationType());
-    AbstractFakeIndex<?, ?, ?> idx = setupRepoWithFourChanges();
-    final int LIMIT = 3;
-
-    projectOperations
-        .project(allProjectsName)
-        .forUpdate()
-        .removeAllAccessSections()
-        .add(allow(Permission.READ).ref("refs/*").group(SystemGroupBackend.REGISTERED_USERS))
-        .update();
-
-    // Set queryLimit to 3
-    projectOperations
-        .project(allProjects)
-        .forUpdate()
-        .add(allowCapability(QUERY_LIMIT).group(ANONYMOUS_USERS).range(0, LIMIT))
-        .update();
-
-    @SuppressWarnings("unused")
-    var unused = requestContext.setContext(anonymousUserProvider::get);
-
-    List<ChangeInfo> result = newQuery("status:new").withLimit(LIMIT).get();
-    assertThat(result.size()).isEqualTo(0);
-    assertThatSearchQueryWasNotPaginated(idx.getQueryCount());
-    assertThat(idx.getResultsSizes().get(0)).isEqualTo(LIMIT + 1);
-  }
-
-  @Test
-  @UseClockStep
   public void invisibleChangesPaginatedWithPagination() throws Exception {
-    assumeFalse(PaginationType.NONE == getCurrentPaginationType());
-
     AbstractFakeIndex<?, ?, ?> idx = setupRepoWithFourChanges();
     final int LIMIT = 3;
 
diff --git a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 987a87e..82c9065 100644
--- a/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -20,6 +20,7 @@
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -109,4 +110,28 @@
     Change[] expected = new Change[] {change6, change5, change4, change3, change2, change1};
     assertQuery(newQuery("project:repo").withNoLimit(), expected);
   }
+
+  @Test
+  public void skipChangesNotVisible() throws Exception {
+    // create 1 new change on a repo
+    Project.NameKey project = Project.nameKey("repo");
+    repo = createAndOpenProject(project);
+    Change visibleChange = insert(project, newChangeWithStatus(repo, Change.Status.NEW));
+    Change[] expected = new Change[] {visibleChange};
+
+    // pagination does not need to restart the datasource, the request is fulfilled
+    assertQuery(newQuery("status:new").withLimit(1), expected);
+
+    // create 2 new private changes
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+
+    Change invisibleChange1 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    Change invisibleChange2 = insert(project, newChangeWithStatus(repo, Change.Status.NEW), user2);
+    gApi.changes().id(invisibleChange1.getKey().get()).setPrivate(true, null);
+    gApi.changes().id(invisibleChange2.getKey().get()).setPrivate(true, null);
+
+    // pagination should back-fill when the results skipped because of the visibility
+    assertQuery(newQuery("status:new").withLimit(1), expected);
+  }
 }
diff --git a/plugins/replication b/plugins/replication
index 012f042..aab6937 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 012f04240eafe6dfa21fd94e012e97498881c621
+Subproject commit aab69373db6152ea98e7627f816fa3d777d5fe46
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 23c5746..50ec339 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -42,6 +42,7 @@
   ChangeActionDialog,
   ChangeInfo,
   CherryPickInput,
+  CommentThread,
   CommitId,
   InheritedBooleanInfo,
   isDetailedLabelInfo,
@@ -116,6 +117,8 @@
 import {ParsedChangeInfo} from '../../../types/types';
 import {configModelToken} from '../../../models/config/config-model';
 import {readJSONResponsePayload} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {commentsModelToken} from '../../../models/comments/comments-model';
+import {when} from 'lit/directives/when.js';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -366,6 +369,8 @@
 
   @query('#confirmDeleteEditDialog') confirmDeleteEditDialog?: GrDialog;
 
+  @query('#confirmPublishEditDialog') confirmPublishEditDialog?: GrDialog;
+
   @query('#moreActions') moreActions?: GrDropdown;
 
   @query('#secondaryActions') secondaryActions?: HTMLElement;
@@ -480,6 +485,8 @@
 
   @state() pluginsLoaded = false;
 
+  @state() threadsWithSuggestions?: CommentThread[];
+
   private readonly restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
@@ -496,6 +503,8 @@
 
   private readonly getNavigation = resolve(this, navigationToken);
 
+  private readonly getCommentsModel = resolve(this, commentsModelToken);
+
   constructor() {
     super();
     subscribe(
@@ -568,6 +577,11 @@
       () => this.getConfigModel().repoConfig$,
       config => (this.privateByDefault = config?.private_by_default)
     );
+    subscribe(
+      this,
+      () => this.getCommentsModel().threadsWithSuggestions$,
+      x => (this.threadsWithSuggestions = x)
+    );
   }
 
   override connectedCallback() {
@@ -620,6 +634,15 @@
         .hidden {
           display: none;
         }
+        .info {
+          background-color: var(--info-background);
+          padding: var(--spacing-l) var(--spacing-xl);
+          margin-bottom: var(--spacing-l);
+        }
+        .info gr-icon {
+          color: var(--selected-foreground);
+          margin-right: var(--spacing-xl);
+        }
         @media screen and (max-width: 50em) {
           #mainContent {
             flex-wrap: wrap;
@@ -786,6 +809,27 @@
             Do you really want to delete the edit?
           </div>
         </gr-dialog>
+        <gr-dialog
+          id="confirmPublishEditDialog"
+          class="confirmDialog"
+          confirm-label="Publish"
+          confirm-on-enter=""
+          @cancel=${this.handleConfirmDialogCancel}
+          @confirm=${this.handlePublishEditConfirm}
+        >
+          <div class="header" slot="header">Publish Change Edit</div>
+          <div class="main" slot="main">
+            ${when(
+              this.numberOfThreadsWithSuggestions() > 0,
+              () => html`<p class="info">
+                <gr-icon id="icon" icon="info" small></gr-icon>
+                Heads Up! ${this.numberOfThreadsWithSuggestions()} comments have
+                suggestions you can apply before publishing
+              </p>`
+            )}
+            Do you really want to publish the edit?
+          </div>
+        </gr-dialog>
       </dialog>
     `;
   }
@@ -1691,6 +1735,23 @@
     );
   }
 
+  private handlePublishEditConfirm() {
+    this.hideAllDialogs();
+
+    if (!this.actions.publishEdit) return;
+
+    // We need to make sure that all cached version of a change
+    // edit are deleted.
+    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
+
+    this.fireAction(
+      '/edit:publish',
+      assertUIActionInfo(this.actions.publishEdit),
+      false,
+      {notify: NotifyType.NONE}
+    );
+  }
+
   // private but used in test
   handleSubmitConfirm() {
     if (!this.canSubmitChange()) {
@@ -2044,18 +2105,8 @@
   }
 
   private handlePublishEditTap() {
-    if (!this.actions.publishEdit) return;
-
-    // We need to make sure that all cached version of a change
-    // edit are deleted.
-    this.getStorage().eraseEditableContentItemsForChangeEdit(this.changeNum);
-
-    this.fireAction(
-      '/edit:publish',
-      assertUIActionInfo(this.actions.publishEdit),
-      false,
-      {notify: NotifyType.NONE}
-    );
+    assertIsDefined(this.confirmPublishEditDialog, 'confirmPublishEditDialog');
+    this.showActionDialog(this.confirmPublishEditDialog);
   }
 
   private handleRebaseEditTap() {
@@ -2212,6 +2263,11 @@
   private handleStopEditTap() {
     fireNoBubbleNoCompose(this, 'stop-edit-tap', {});
   }
+
+  private numberOfThreadsWithSuggestions() {
+    if (!this.threadsWithSuggestions) return 0;
+    return this.threadsWithSuggestions.length;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 8275c0b..a926f26 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -278,6 +278,18 @@
                 Do you really want to delete the edit?
               </div>
             </gr-dialog>
+            <gr-dialog
+              class="confirmDialog"
+              confirm-label="Publish"
+              confirm-on-enter=""
+              id="confirmPublishEditDialog"
+              role="dialog"
+            >
+              <div class="header" slot="header">Publish Change Edit</div>
+              <div class="main" slot="main">
+                Do you really want to publish the edit?
+              </div>
+            </gr-dialog>
           </dialog>
         `
       );
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
index 0bf49db..1a18e9c 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.ts
@@ -98,7 +98,10 @@
 } from '../../../utils/attention-set-util';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 import {resolve} from '../../../models/dependency';
-import {changeModelToken} from '../../../models/change/change-model';
+import {
+  changeModelToken,
+  updateRevisionsWithCommitShas,
+} from '../../../models/change/change-model';
 import {LabelNameToValuesMap, PatchSetNumber} from '../../../api/rest-api';
 import {css, html, PropertyValues, LitElement, nothing} from 'lit';
 import {sharedStyles} from '../../../styles/shared-styles';
@@ -1459,8 +1462,10 @@
     return this.saveReview(reviewInput, errFn)
       .then(result => {
         this.getChangeModel().updateStateChange(
-          GrReviewerUpdatesParser.parse(
-            result?.change_info as ChangeViewChangeInfo
+          updateRevisionsWithCommitShas(
+            GrReviewerUpdatesParser.parse(
+              result?.change_info as ChangeViewChangeInfo
+            )
           )
         );
 
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 3b679fd..0a31677 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -315,6 +315,8 @@
           fixSuggestion.replacements
         );
       } else {
+        // TODO(b/227463363) Remove once Robot Comments are deprecated.
+        // We don't use this for user suggestions or comments.fix_suggestions.
         res = await this.restApiService.getRobotCommentFixPreview(
           this.changeNum,
           this.patchNum,
diff --git a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
index 4d5889f..9e78e2a 100644
--- a/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
+++ b/polygerrit-ui/app/elements/shared/gr-fix-suggestions/gr-fix-suggestions.ts
@@ -20,6 +20,7 @@
 import {OpenFixPreviewEventDetail} from '../../../types/events';
 import {pluginLoaderToken} from '../gr-js-api-interface/gr-plugin-loader';
 import {SuggestionsProvider} from '../../../api/suggestions';
+import {PROVIDED_FIX_ID} from '../../../utils/comment-util';
 
 /**
  * gr-fix-suggestions is UI for comment.fix_suggestions.
@@ -149,7 +150,11 @@
       fixSuggestions: this.comment.fix_suggestions.map(s => {
         return {
           ...s,
-          description: 'Suggested Edit from comment',
+          fix_id: PROVIDED_FIX_ID,
+          description:
+            this.suggestionsProvider?.getFixSuggestionTitle?.(
+              this.comment?.fix_suggestions
+            ) || 'Suggested edit',
         };
       }),
       patchNum: this.comment.patch_set,
diff --git a/polygerrit-ui/app/models/comments/comments-model.ts b/polygerrit-ui/app/models/comments/comments-model.ts
index 41b47ef..f9a8401 100644
--- a/polygerrit-ui/app/models/comments/comments-model.ts
+++ b/polygerrit-ui/app/models/comments/comments-model.ts
@@ -25,9 +25,12 @@
   convertToCommentInput,
   createNew,
   createNewPatchsetLevel,
+  getFirstComment,
+  hasSuggestion,
   id,
   isDraftThread,
   isNewThread,
+  isUnresolved,
   reportingDetails,
 } from '../../utils/comment-util';
 import {deepEqual} from '../../utils/deep-util';
@@ -429,6 +432,17 @@
     threads.filter(t => !isNewThread(t) && isDraftThread(t))
   );
 
+  public readonly threadsWithSuggestions$ = select(
+    combineLatest([this.threads$, this.changeModel.latestPatchNum$]),
+    ([threads, latestPs]) =>
+      threads.filter(
+        t =>
+          isUnresolved(t) &&
+          hasSuggestion(t) &&
+          getFirstComment(t)?.patch_set === latestPs
+      )
+  );
+
   public readonly commentedPaths$ = select(
     combineLatest([
       this.changeComments$,
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 364f372..e82b328 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -262,6 +262,15 @@
   return isRobot(getFirstComment(thread));
 }
 
+export function hasSuggestion(thread: CommentThread): boolean {
+  const firstComment = getFirstComment(thread);
+  if (!firstComment) return false;
+  return (
+    hasUserSuggestion(firstComment) ||
+    firstComment.fix_suggestions?.[0] !== undefined
+  );
+}
+
 export function hasHumanReply(thread: CommentThread): boolean {
   return countComments(thread) > 1 && !isRobot(getLastComment(thread));
 }