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();
+ }
+ }
}