Merge "Update h2 to 2.5.250"
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index ed9fcd8..a2fecab 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -140,11 +140,9 @@
   requiring `Code-Review` approvals from all reviewers].
 * In the REST API, service user accounts are tagged with `SERVICE_USER` (see the
   `tags` field in link:rest-api-accounts.html#account-info[AccountInfo]).
-* Change indexing is done synchronously for service users only if
+* Change indexing is done synchronously for service users, even if
   link:config-gerrit.html#index.indexChangesAsync[asynchronous change indexing]
-  is enabled in the Gerrit config (and the
-  `GerritBackendFeature__do_change_indexing_asynchronously_for_non_service_users`
-  experiment is enabled).
+  is enabled in the Gerrit config.
 * For Gerrit servers at Google, querying the change index uses strong reads only
   for service users. Other users may get results that are stale by a few
   seconds.
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 9b3712d..3b03d60 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -4084,10 +4084,10 @@
 [[index.indexChangesAsync]]index.indexChangesAsync::
 +
 On BatchUpdate, do not await indexing completion before returning the request
-to the user (WEB_BROWSER requests only).
-This has an advantage of faster UI (because indexing latency does not contribute
-to the write request latency) and disadvantage that the indexing result might not be
-immediately available after the write request.
+to the user (WEB_BROWSER requests and non-service user requests, e.g. git push).
+This has an advantage of faster response times (because indexing latency does not
+contribute to the write request latency) and a disadvantage that the indexing result
+might not be immediately available after the write request.
 +
 Defaults to `false`.
 
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 1106883..764006f 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -190,6 +190,11 @@
     return get(LAST_LOGIN_EXTERNAL_ID_PROPERTY_KEY);
   }
 
+  /** Returns the immutable {@link PropertyMap} containing properties attached to this user. */
+  public PropertyMap properties() {
+    return properties;
+  }
+
   /**
    * Checks if the current user has the same account id of another.
    *
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index abd67be..9ff59c6 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -263,7 +263,7 @@
   private final Provider<SocketAddress> remotePeerProvider;
   private final Account.Id accountId;
 
-  private AccountState state;
+  private volatile AccountState state;
   private boolean loadedAllEmails;
   private Set<String> invalidEmails;
   private GroupMembership effectiveGroups;
@@ -293,10 +293,40 @@
         groupBackend,
         enablePeerIPInReflogRecord,
         remotePeerProvider,
-        state.account().id(),
+        state,
         realUser,
         PropertyMap.EMPTY,
         permissionMode);
+  }
+
+  private IdentifiedUser(
+      AuthConfig authConfig,
+      Realm realm,
+      String anonymousCowardName,
+      RefLogIdentityProvider refLogIdentityProvider,
+      Provider<String> canonicalUrl,
+      AccountCache accountCache,
+      GroupBackend groupBackend,
+      Boolean enablePeerIPInReflogRecord,
+      Provider<SocketAddress> remotePeerProvider,
+      AccountState state,
+      @Nullable CurrentUser realUser,
+      PropertyMap properties,
+      ImpersonationPermissionMode permissionMode) {
+    this(
+        authConfig,
+        realm,
+        anonymousCowardName,
+        refLogIdentityProvider,
+        canonicalUrl,
+        accountCache,
+        groupBackend,
+        enablePeerIPInReflogRecord,
+        remotePeerProvider,
+        state.account().id(),
+        realUser,
+        properties,
+        permissionMode);
     this.state = state;
   }
 
@@ -573,19 +603,30 @@
             throw e;
           };
     }
-    return new IdentifiedUser(
-        authConfig,
-        realm,
-        anonymousCowardName,
-        refLogIdentityProvider,
-        Providers.of(canonicalUrl.get()),
-        accountCache,
-        groupBackend,
-        enablePeerIPInReflogRecord,
-        remotePeer,
-        state,
-        realUser,
-        permissionMode);
+    // Note: Lazy-loaded caches (effectiveGroups, validEmails, invalidEmails) are intentionally
+    // not copied to prevent cross-thread reference leaks or data races on mutable collections;
+    // they are safely re-evaluated if needed by the background thread.
+    CurrentUser copyRealUser = (realUser == this) ? null : realUser;
+    if (copyRealUser != null && copyRealUser.isIdentifiedUser()) {
+      copyRealUser = ((IdentifiedUser) copyRealUser).materializedCopy();
+    }
+    IdentifiedUser copy =
+        new IdentifiedUser(
+            authConfig,
+            realm,
+            anonymousCowardName,
+            refLogIdentityProvider,
+            Providers.of(canonicalUrl.get()),
+            accountCache,
+            groupBackend,
+            enablePeerIPInReflogRecord,
+            remotePeer,
+            state(),
+            copyRealUser,
+            properties(),
+            permissionMode);
+    copy.setAccessPath(getAccessPath());
+    return copy;
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
index 678fea1..c2dec1d 100644
--- a/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/H2CustomLockAccountPatchReviewStore.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.SitePaths;
 import java.lang.reflect.InvocationTargetException;
@@ -24,28 +22,26 @@
 import java.sql.DriverManager;
 import java.sql.SQLException;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Lock;
 import org.eclipse.jgit.lib.Config;
 
 /**
  * Abstract base for H2 stores that replace H2's built-in file locking with a custom mechanism.
  *
- * <p>H2 is opened with {@code FILE_LOCK=NO}; subclasses implement {@link #tryAcquireLock()} to make
- * a single lock attempt. The retry loop with exponential backoff is handled here.
+ * <p>H2 is opened with {@code FILE_LOCK=NO}; subclasses implement {@link #newLock()}, returning a
+ * {@link Lock} whose {@link Lock#tryLock(long, TimeUnit)} implementation is responsible for its own
+ * retry and backoff strategy, up to the given wait time.
  */
 abstract class H2CustomLockAccountPatchReviewStore extends H2AccountPatchReviewStore {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-  private static final long INITIAL_BACKOFF_MS = 1;
-  private static final long MAX_BACKOFF_MS = 500;
-  static final long DEFAULT_LOCK_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(30);
-
+  private static final long DEFAULT_LOCK_TIMEOUT_MS = TimeUnit.SECONDS.toMillis(30);
   private final String url;
   private final long lockTimeoutMs;
+  private Lock lockInstance;
 
   protected H2CustomLockAccountPatchReviewStore(Config cfg, SitePaths sitePaths) {
     super();
-    this.url =
-        JdbcAccountPatchReviewStore.getUrl(cfg, sitePaths) + ";FILE_LOCK=NO;DB_CLOSE_DELAY=0";
-    this.lockTimeoutMs =
+    url = JdbcAccountPatchReviewStore.getUrl(cfg, sitePaths) + ";FILE_LOCK=NO;DB_CLOSE_DELAY=0";
+    lockTimeoutMs =
         ConfigUtil.getTimeUnit(
             cfg,
             JdbcAccountPatchReviewStore.ACCOUNT_PATCH_REVIEW_DB,
@@ -59,52 +55,37 @@
     return lockTimeoutMs;
   }
 
-  /**
-   * Makes a single attempt to acquire the lock.
-   *
-   * @return a {@link Runnable} that releases the lock, or {@code null} if the lock is currently
-   *     held by another process (retry-able)
-   * @throws SQLException on a hard, non-retryable error
-   */
-  @Nullable
-  protected abstract Runnable tryAcquireLock() throws SQLException;
+  /** Creates a new, not-yet-acquired {@link Lock}. */
+  protected abstract Lock newLock();
 
-  private Runnable acquireExclusiveLock() throws SQLException {
-    long backoffMs = INITIAL_BACKOFF_MS;
-    long deadline = System.currentTimeMillis() + lockTimeoutMs;
-    while (true) {
-      Runnable unlock = tryAcquireLock();
-      if (unlock != null) {
-        return unlock;
-      }
-      long remaining = deadline - System.currentTimeMillis();
-      if (remaining <= 0) {
-        throw new SQLException("Could not acquire H2 lock within " + lockTimeoutMs + " ms");
-      }
-      long sleepMs = Math.min(backoffMs, remaining);
-      logger.atFine().log("H2 lock held by another process, retrying in %d ms", sleepMs);
-      try {
-        Thread.sleep(sleepMs);
-      } catch (InterruptedException ie) {
-        Thread.currentThread().interrupt();
-        throw new SQLException("Interrupted while waiting for H2 lock", ie);
-      }
-      backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
+  protected synchronized Lock lock() {
+    if (lockInstance == null) {
+      lockInstance = newLock();
     }
+    return lockInstance;
   }
 
   @Override
   public Connection getConnection() throws SQLException {
-    Runnable unlock = acquireExclusiveLock();
+    Lock lock = lock();
     try {
-      return lockingConnection(DriverManager.getConnection(url), unlock);
+      if (!lock.tryLock(lockTimeoutMs, TimeUnit.MILLISECONDS)) {
+        throw new SQLException("Could not acquire H2 lock within " + lockTimeoutMs + " ms");
+      }
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new SQLException("Interrupted while waiting for H2 lock", e);
+    }
+
+    try {
+      return lockingConnection(DriverManager.getConnection(url), lock);
     } catch (SQLException e) {
-      unlock.run();
+      lock.unlock();
       throw e;
     }
   }
 
-  private static Connection lockingConnection(Connection con, Runnable unlock) {
+  private static Connection lockingConnection(Connection con, Lock lock) {
     return (Connection)
         Proxy.newProxyInstance(
             Connection.class.getClassLoader(),
@@ -116,7 +97,7 @@
                 throw e.getCause();
               } finally {
                 if ("close".equals(method.getName())) {
-                  unlock.run();
+                  lock.unlock();
                 }
               }
             });
diff --git a/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
index d930013..374bd90 100644
--- a/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
@@ -18,14 +18,15 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.File;
 import java.io.IOException;
-import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.Condition;
+import java.util.concurrent.locks.Lock;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.internal.storage.file.LockFile;
 import org.eclipse.jgit.lib.Config;
@@ -41,6 +42,8 @@
 public class H2JGitLockAccountPatchReviewStore extends H2CustomLockAccountPatchReviewStore {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String H2_DB_URL_PREFIX = "jdbc:h2:file:";
+  private static final long INITIAL_BACKOFF_MS = 1;
+  private static final long MAX_BACKOFF_MS = 500;
   private final File lockTarget;
 
   @Inject
@@ -73,14 +76,70 @@
         lockTarget, getLockTimeoutMs());
   }
 
-  @Nullable
+  /**
+   * Creates a {@link Lock} whose {@link Lock#tryLock(long, TimeUnit)} creates and acquires a
+   * jgit-style {@link LockFile}, retrying with backoff until the given wait time elapses.
+   */
   @Override
-  protected Runnable tryAcquireLock() throws SQLException {
-    LockFile lock = new LockFile(lockTarget);
-    try {
-      return lock.lock() ? lock::unlock : null;
-    } catch (IOException e) {
-      throw new SQLException("Failed to acquire jgit-style lock for H2 database", e);
-    }
+  protected Lock newLock() {
+    return new Lock() {
+      private LockFile lockFile;
+
+      @Override
+      public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
+        long backoffMs = INITIAL_BACKOFF_MS;
+        long deadline = System.currentTimeMillis() + unit.toMillis(time);
+        while (!tryLock()) {
+          long remaining = deadline - System.currentTimeMillis();
+          if (remaining <= 0) {
+            return false;
+          }
+          long sleepMs = Math.min(backoffMs, remaining);
+          logger.atFine().log("H2 lock held by another process, retrying in %d ms", sleepMs);
+          Thread.sleep(sleepMs);
+          backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
+        }
+        return true;
+      }
+
+      @Override
+      public synchronized boolean tryLock() {
+        if (lockFile != null) {
+          return false;
+        }
+
+        try {
+          LockFile currLock = new LockFile(lockTarget);
+          if (currLock.lock()) {
+            lockFile = currLock;
+            return true;
+          }
+        } catch (IOException e) {
+          logger.atInfo().withCause(e).log("Failed to acquire jgit-style lock for H2 database");
+        }
+        return false;
+      }
+
+      @Override
+      public synchronized void unlock() {
+        lockFile.unlock();
+        lockFile = null;
+      }
+
+      @Override
+      public void lock() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public void lockInterruptibly() {
+        throw new UnsupportedOperationException();
+      }
+
+      @Override
+      public Condition newCondition() {
+        throw new UnsupportedOperationException();
+      }
+    };
   }
 }
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 5e60aa9..d5303e7 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -561,10 +561,11 @@
     }
   }
 
-  // For upstream implementation, AccessPath.WEB_BROWSER is never set, so the method will always
-  // return false.
+  // Asynchronous change indexing is performed for WEB_BROWSER requests or for non-service
+  // users (such as interactive git pushes) when enabled in gerrit.config.
+  @VisibleForTesting
   @UsedAt(GOOGLE)
-  private boolean indexAsync() {
+  boolean indexAsync() {
     if (!gerritConfig.getBoolean("index", "indexChangesAsync", false)) {
       return false;
     }
diff --git a/javatests/com/google/gerrit/server/IdentifiedUserTest.java b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
index f726be3..5bcb6bb 100644
--- a/javatests/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/javatests/com/google/gerrit/server/IdentifiedUserTest.java
@@ -130,4 +130,79 @@
     /* assert again to test cached email address by IdentifiedUser.invalidEmails */
     assertThat(identifiedUser.hasEmailAddress("non-exist@email.com")).isFalse();
   }
+
+  @Test
+  public void materializedCopyPreservesAccessPath() {
+    assertThat(identifiedUser.getAccessPath()).isEqualTo(AccessPath.UNKNOWN);
+    assertThat(identifiedUser.materializedCopy().getAccessPath()).isEqualTo(AccessPath.UNKNOWN);
+
+    for (AccessPath path : AccessPath.values()) {
+      identifiedUser.setAccessPath(path);
+      IdentifiedUser copy = identifiedUser.materializedCopy();
+      assertThat(copy.getAccessPath()).isEqualTo(path);
+    }
+  }
+
+  @Test
+  public void materializedCopyAccessPathMutationIsIsolated() {
+    identifiedUser.setAccessPath(AccessPath.GIT);
+    IdentifiedUser copy = identifiedUser.materializedCopy();
+    assertThat(copy.getAccessPath()).isEqualTo(AccessPath.GIT);
+
+    copy.setAccessPath(AccessPath.WEB_BROWSER);
+    assertThat(identifiedUser.getAccessPath()).isEqualTo(AccessPath.GIT);
+    assertThat(copy.getAccessPath()).isEqualTo(AccessPath.WEB_BROWSER);
+  }
+
+  @Test
+  public void materializedCopyPreservesPropertyMap() {
+    PropertyMap.Key<String> testKey = PropertyMap.key();
+    PropertyMap properties = PropertyMap.builder().put(testKey, "customValue").build();
+    IdentifiedUser userWithProps =
+        identifiedUserFactory.forTest(identifiedUser.getAccountId(), properties);
+    assertThat(userWithProps.get(testKey)).hasValue("customValue");
+
+    IdentifiedUser copy = userWithProps.materializedCopy();
+    assertThat(copy.get(testKey)).hasValue("customValue");
+    assertThat(copy.properties()).isSameInstanceAs(properties);
+  }
+
+  @Test
+  public void materializedCopyRealUserIsIsolatedAndSelfReferencing() {
+    assertThat(identifiedUser.getRealUser()).isSameInstanceAs(identifiedUser);
+
+    IdentifiedUser copy = identifiedUser.materializedCopy();
+    assertThat(copy.getRealUser()).isSameInstanceAs(copy);
+    assertThat(copy.getRealUser()).isNotSameInstanceAs(identifiedUser);
+  }
+
+  @Test
+  public void materializedCopyPreservesImpersonatedRealUser() {
+    Account.Id callerId = Account.id(2);
+    IdentifiedUser caller = identifiedUserFactory.create(callerId);
+    caller.setAccessPath(AccessPath.REST_API);
+
+    IdentifiedUser impersonated =
+        identifiedUserFactory.runAs(
+            /* remotePeer= */ null,
+            identifiedUser.getAccountId(),
+            caller,
+            IdentifiedUser.ImpersonationPermissionMode.THIS_USER);
+    assertThat(impersonated.isImpersonated()).isTrue();
+    assertThat(impersonated.getRealUser().getAccountId()).isEqualTo(callerId);
+
+    IdentifiedUser copy = impersonated.materializedCopy();
+    assertThat(copy.isImpersonated()).isTrue();
+    assertThat(copy.getRealUser().getAccountId()).isEqualTo(callerId);
+    assertThat(copy.getRealUser().getAccessPath()).isEqualTo(AccessPath.REST_API);
+    assertThat(copy.getRealUser()).isNotSameInstanceAs(caller);
+  }
+
+  @Test
+  public void materializedCopyWithUnloadedStateDoesNotThrowNpe() {
+    IdentifiedUser freshUser = identifiedUserFactory.create(Account.id(3));
+    IdentifiedUser copy = freshUser.materializedCopy();
+    assertThat(copy.getAccountId()).isEqualTo(Account.id(3));
+    assertThat(copy.getAccessPath()).isEqualTo(AccessPath.UNKNOWN);
+  }
 }
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 0894762..c6a3abc 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -42,6 +43,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.git.LockFailureException;
 import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -49,11 +51,15 @@
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.change.AbandonOp;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.PatchSetInserter;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.db.GroupDelta;
+import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -112,6 +118,7 @@
   @Inject private PatchSetInserter.Factory patchSetInserterFactory;
   @Inject private Provider<CurrentUser> user;
   @Inject private Sequences sequences;
+  @Inject @GerritServerConfig private Config serverConfig;
   @Inject private AddReviewersOp.Factory addReviewersOpFactory;
   @Inject private DynamicSet<AttentionSetListener> attentionSetListeners;
   @Inject private AccountManager accountManager;
@@ -121,6 +128,10 @@
   @Inject private AbandonOp.Factory abandonOpFactory;
   @Inject @GerritPersonIdent private PersonIdent serverIdent;
   @Inject private RetryHelper retryHelper;
+  @Inject private com.google.gerrit.server.account.GroupCache groupCache;
+
+  @Inject @com.google.gerrit.server.ServerInitiated
+  private Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
@@ -959,4 +970,66 @@
       postUpdateUser = ctx.getUser();
     }
   }
+
+  @Test
+  public void indexAsync_disabledByDefault() throws Exception {
+    try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isFalse();
+    }
+  }
+
+  @Test
+  public void indexAsync_whenEnabled_returnsTrueForWebBrowser() throws Exception {
+    serverConfig.setBoolean("index", null, "indexChangesAsync", true);
+
+    IdentifiedUser u = user.get().asIdentifiedUser();
+    u.setAccessPath(AccessPath.WEB_BROWSER);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, u, TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isTrue();
+    }
+  }
+
+  @Test
+  public void indexAsync_whenEnabled_returnsTrueForNonServiceUserGitPush() throws Exception {
+    serverConfig.setBoolean("index", null, "indexChangesAsync", true);
+
+    IdentifiedUser u = user.get().asIdentifiedUser();
+    u.setAccessPath(AccessPath.GIT);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, u, TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isTrue();
+    }
+  }
+
+  @Test
+  public void indexAsync_whenEnabled_returnsFalseForServiceUserGitPush() throws Exception {
+    serverConfig.setBoolean("index", null, "indexChangesAsync", true);
+
+    Account.Id serviceUserAccountId =
+        accountManager.authenticate(authRequestFactory.createForUser("robot")).getAccountId();
+    IdentifiedUser serviceUser = userFactory.create(serviceUserAccountId);
+    serviceUser.setAccessPath(AccessPath.GIT);
+
+    // Add serviceUser to "Service Users" group
+    AccountGroup.UUID serviceUsersUuid =
+        groupCache
+            .get(AccountGroup.nameKey(ServiceUserClassifier.SERVICE_USERS))
+            .orElseThrow()
+            .getGroupUUID();
+    GroupDelta delta =
+        GroupDelta.builder()
+            .setMemberModification(
+                members ->
+                    ImmutableSet.<Account.Id>builder()
+                        .addAll(members)
+                        .add(serviceUserAccountId)
+                        .build())
+            .build();
+    groupsUpdateProvider.get().updateGroup(serviceUsersUuid, delta);
+
+    try (BatchUpdate bu = batchUpdateFactory.create(project, serviceUser, TimeUtil.now())) {
+      assertThat(bu.indexAsync()).isFalse();
+    }
+  }
 }