| // Copyright (C) 2017 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.server.update; |
| |
| import static com.google.common.base.Preconditions.checkArgument; |
| import static com.google.common.base.Preconditions.checkState; |
| import static com.google.common.truth.Truth.assertThat; |
| import static com.google.gerrit.testing.GerritJUnit.assertThrows; |
| import static com.google.gerrit.testing.TestActionRefUpdateContext.openTestRefUpdateContext; |
| import static org.mockito.Mockito.times; |
| import static org.mockito.Mockito.verify; |
| |
| import com.github.rholder.retry.Attempt; |
| import com.github.rholder.retry.RetryException; |
| import com.github.rholder.retry.RetryListener; |
| import com.google.common.cache.Cache; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.Account; |
| 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.SubmissionId; |
| import com.google.gerrit.entities.SubmitRecord; |
| import com.google.gerrit.extensions.client.ReviewerState; |
| import com.google.gerrit.extensions.events.AttentionSetListener; |
| import com.google.gerrit.extensions.registration.DynamicSet; |
| import com.google.gerrit.extensions.restapi.ResourceConflictException; |
| import com.google.gerrit.git.LockFailureException; |
| import com.google.gerrit.git.RefUpdateUtil; |
| import com.google.gerrit.server.CurrentUser; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.InternalUser; |
| 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.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.git.GitRepositoryManager; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.notedb.ChangeUpdate; |
| import com.google.gerrit.server.notedb.ReviewerStateInternal; |
| import com.google.gerrit.server.patch.DiffSummary; |
| import com.google.gerrit.server.patch.DiffSummaryKey; |
| import com.google.gerrit.server.update.RetryableAction.ActionType; |
| import com.google.gerrit.server.update.context.RefUpdateContext; |
| import com.google.gerrit.server.util.time.TimeUtil; |
| import com.google.gerrit.testing.InMemoryTestEnvironment; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.name.Named; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicInteger; |
| import org.eclipse.jgit.junit.TestRepository; |
| import org.eclipse.jgit.lib.BatchRefUpdate; |
| import org.eclipse.jgit.lib.Config; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.lib.RefUpdate; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.Rule; |
| import org.junit.Test; |
| import org.mockito.ArgumentCaptor; |
| import org.mockito.Captor; |
| import org.mockito.Mock; |
| import org.mockito.junit.MockitoJUnit; |
| import org.mockito.junit.MockitoRule; |
| |
| public class BatchUpdateTest { |
| private static final int MAX_UPDATES = 4; |
| private static final int MAX_PATCH_SETS = 3; |
| |
| @Rule |
| public InMemoryTestEnvironment testEnvironment = |
| new InMemoryTestEnvironment( |
| () -> { |
| Config cfg = new Config(); |
| cfg.setInt("change", null, "maxFiles", 2); |
| cfg.setInt("change", null, "maxPatchSets", MAX_PATCH_SETS); |
| cfg.setInt("change", null, "maxUpdates", MAX_UPDATES); |
| cfg.setString("index", null, "type", "fake"); |
| return cfg; |
| }); |
| |
| @Inject private BatchUpdate.Factory batchUpdateFactory; |
| @Inject private ChangeInserter.Factory changeInserterFactory; |
| @Inject private ChangeNotes.Factory changeNotesFactory; |
| @Inject private GitRepositoryManager repoManager; |
| @Inject private PatchSetInserter.Factory patchSetInserterFactory; |
| @Inject private Provider<CurrentUser> user; |
| @Inject private Sequences sequences; |
| @Inject private AddReviewersOp.Factory addReviewersOpFactory; |
| @Inject private DynamicSet<AttentionSetListener> attentionSetListeners; |
| @Inject private AccountManager accountManager; |
| @Inject private AuthRequest.Factory authRequestFactory; |
| @Inject private IdentifiedUser.GenericFactory userFactory; |
| @Inject private InternalUser.Factory internalUserFactory; |
| @Inject private AbandonOp.Factory abandonOpFactory; |
| @Inject @GerritPersonIdent private PersonIdent serverIdent; |
| @Inject private RetryHelper retryHelper; |
| |
| @Rule public final MockitoRule mockito = MockitoJUnit.rule(); |
| |
| @Captor ArgumentCaptor<AttentionSetListener.Event> attentionSetEventCaptor; |
| @Mock private AttentionSetListener attentionSetListener; |
| |
| @Inject |
| private @Named("diff_summary") Cache<DiffSummaryKey, DiffSummary> diffSummaryCache; |
| |
| private Project.NameKey project; |
| private TestRepository<Repository> repo; |
| |
| private RefUpdateContext testRefUpdateContext; |
| |
| @Before |
| public void setUp() throws Exception { |
| project = Project.nameKey("test"); |
| |
| Repository inMemoryRepo = repoManager.createRepository(project); |
| repo = new TestRepository<>(inMemoryRepo); |
| // All tests here are low level. Open context here to avoid repeated code in multiple tests. |
| testRefUpdateContext = openTestRefUpdateContext(); |
| } |
| |
| @After |
| public void tearDown() { |
| testRefUpdateContext.close(); |
| } |
| |
| @Test |
| public void addRefUpdateFromFastForwardCommit() throws Exception { |
| RevCommit masterCommit = repo.branch("master").commit().create(); |
| RevCommit branchCommit = repo.branch("branch").commit().parent(masterCommit).create(); |
| |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addRepoOnlyOp( |
| new RepoOnlyOp() { |
| @Override |
| public void updateRepo(RepoContext ctx) throws Exception { |
| ctx.addRefUpdate(masterCommit.getId(), branchCommit.getId(), "refs/heads/master"); |
| } |
| }); |
| bu.execute(); |
| } |
| assertThat(repo.getRepository().exactRef("refs/heads/master").getObjectId()) |
| .isEqualTo(branchCommit.getId()); |
| } |
| |
| @Test |
| public void batchUpdateThatChangeAttentionSetAsInternalUser() throws Exception { |
| Change.Id id = createChangeWithUpdates(1); |
| attentionSetListeners.add("test", attentionSetListener); |
| |
| Account.Id reviewer = |
| accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId(); |
| |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp( |
| id, |
| addReviewersOpFactory.create( |
| ImmutableSet.of(reviewer), ImmutableList.of(), ReviewerState.REVIEWER, false)); |
| bu.execute(); |
| } |
| |
| try (BatchUpdate bu = |
| batchUpdateFactory.create(project, internalUserFactory.create(), TimeUtil.now())) { |
| bu.addOp(id, abandonOpFactory.create(null, "test abandon")); |
| bu.execute(); |
| } |
| |
| verify(attentionSetListener, times(2)).onAttentionSetChanged(attentionSetEventCaptor.capture()); |
| AttentionSetListener.Event event = attentionSetEventCaptor.getAllValues().get(0); |
| assertThat(event.getChange()._number).isEqualTo(id.get()); |
| assertThat(event.usersAdded()).containsExactly(reviewer.get()); |
| assertThat(event.usersRemoved()).isEmpty(); |
| |
| event = attentionSetEventCaptor.getAllValues().get(1); |
| assertThat(event.getChange()._number).isEqualTo(id.get()); |
| assertThat(event.usersAdded()).isEmpty(); |
| assertThat(event.usersRemoved()).containsExactly(reviewer.get()); |
| } |
| |
| @Test |
| public void cannotExceedMaxUpdates() throws Exception { |
| Change.Id id = createChangeWithUpdates(MAX_UPDATES); |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(id, new AddMessageOp("Excessive update")); |
| ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute); |
| assertThat(thrown) |
| .hasMessageThat() |
| .contains("Change " + id + " may not exceed " + MAX_UPDATES); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES); |
| assertThat(getMetaId(id)).isEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void cannotExceedMaxUpdatesCountingMultipleChangeUpdatesInSingleBatch() throws Exception { |
| Change.Id id = createChangeWithPatchSets(2); |
| |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(id, new AddMessageOp("Update on PS1", PatchSet.id(id, 1))); |
| bu.addOp(id, new AddMessageOp("Update on PS2", PatchSet.id(id, 2))); |
| ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute); |
| assertThat(thrown) |
| .hasMessageThat() |
| .contains("Change " + id + " may not exceed " + MAX_UPDATES); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES - 1); |
| assertThat(getMetaId(id)).isEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void attentionSetUpdateEventsFiredForSeveralChangesInSingleBatch() throws Exception { |
| Change.Id id1 = createChangeWithUpdates(1); |
| Change.Id id2 = createChangeWithUpdates(1); |
| attentionSetListeners.add("test", attentionSetListener); |
| |
| Account.Id reviewer1 = |
| accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId(); |
| Account.Id reviewer2 = |
| accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId(); |
| |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp( |
| id1, |
| addReviewersOpFactory.create( |
| ImmutableSet.of(reviewer1), ImmutableList.of(), ReviewerState.REVIEWER, false)); |
| bu.addOp( |
| id2, |
| addReviewersOpFactory.create( |
| ImmutableSet.of(reviewer2), ImmutableList.of(), ReviewerState.REVIEWER, false)); |
| bu.execute(); |
| } |
| verify(attentionSetListener, times(2)).onAttentionSetChanged(attentionSetEventCaptor.capture()); |
| AttentionSetListener.Event event1 = attentionSetEventCaptor.getAllValues().get(0); |
| assertThat(event1.getChange()._number).isEqualTo(id1.get()); |
| assertThat(event1.usersAdded()).containsExactly(reviewer1.get()); |
| assertThat(event1.usersRemoved()).isEmpty(); |
| |
| AttentionSetListener.Event event2 = attentionSetEventCaptor.getAllValues().get(1); |
| assertThat(event2.getChange()._number).isEqualTo(id2.get()); |
| assertThat(event2.usersRemoved()).isEmpty(); |
| } |
| |
| @Test |
| public void exceedingMaxUpdatesAllowedWithCompleteNoOp() throws Exception { |
| Change.Id id = createChangeWithUpdates(MAX_UPDATES); |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp( |
| id, |
| new BatchUpdateOp() { |
| @Override |
| public boolean updateChange(ChangeContext ctx) { |
| return false; |
| } |
| }); |
| bu.execute(); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES); |
| assertThat(getMetaId(id)).isEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void exceedingMaxUpdatesAllowedWithNoOpAfterPopulatingUpdate() throws Exception { |
| Change.Id id = createChangeWithUpdates(MAX_UPDATES); |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp( |
| id, |
| new BatchUpdateOp() { |
| @Override |
| public boolean updateChange(ChangeContext ctx) { |
| ctx.getUpdate(ctx.getChange().currentPatchSetId()).setChangeMessage("No-op"); |
| return false; |
| } |
| }); |
| bu.execute(); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES); |
| assertThat(getMetaId(id)).isEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void exceedingMaxUpdatesAllowedWithSubmit() throws Exception { |
| Change.Id id = createChangeWithUpdates(MAX_UPDATES); |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(id, new SubmitOp()); |
| bu.execute(); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1); |
| assertThat(getMetaId(id)).isNotEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void exceedingMaxUpdatesAllowedWithSubmitAfterOtherOp() throws Exception { |
| Change.Id id = createChangeWithPatchSets(2); |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(id, new AddMessageOp("Message on PS1", PatchSet.id(id, 1))); |
| bu.addOp(id, new SubmitOp()); |
| bu.execute(); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1); |
| assertThat(getMetaId(id)).isNotEqualTo(oldMetaId); |
| } |
| // Not possible to write a variant of this test that submits first and adds a message second in |
| // the same batch, since submit always comes last. |
| |
| @Test |
| public void exceedingMaxUpdatesAllowedWithAbandon() throws Exception { |
| Change.Id id = createChangeWithUpdates(MAX_UPDATES); |
| ObjectId oldMetaId = getMetaId(id); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp( |
| id, |
| new BatchUpdateOp() { |
| @Override |
| public boolean updateChange(ChangeContext ctx) { |
| ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId()); |
| update.setChangeMessage("Abandon"); |
| update.setStatus(Change.Status.ABANDONED); |
| return true; |
| } |
| }); |
| bu.execute(); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(MAX_UPDATES + 1); |
| assertThat(getMetaId(id)).isNotEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void limitPatchSetCount_exceed() throws Exception { |
| Change.Id changeId = createChangeWithPatchSets(MAX_PATCH_SETS); |
| ObjectId oldMetaId = getMetaId(changeId); |
| ChangeNotes notes = changeNotesFactory.create(project, changeId); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| ObjectId commitId = |
| repo.amend(notes.getCurrentPatchSet().commitId()).message("kaboom").create(); |
| bu.addOp( |
| changeId, |
| patchSetInserterFactory |
| .create(notes, PatchSet.id(changeId, MAX_PATCH_SETS + 1), commitId) |
| .setMessage("kaboom")); |
| ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute); |
| assertThat(thrown) |
| .hasMessageThat() |
| .contains("Change " + changeId + " may not exceed " + MAX_PATCH_SETS + " patch sets"); |
| } |
| assertThat(changeNotesFactory.create(project, changeId).getPatchSets()).hasSize(MAX_PATCH_SETS); |
| assertThat(getMetaId(changeId)).isEqualTo(oldMetaId); |
| } |
| |
| @Test |
| public void limitFileCount_exceed() throws Exception { |
| Change.Id changeId = createChangeWithUpdates(1); |
| ChangeNotes notes = changeNotesFactory.create(project, changeId); |
| |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| ObjectId commitId = |
| repo.amend(notes.getCurrentPatchSet().commitId()) |
| .add("bar.txt", "bar") |
| .add("baz.txt", "baz") |
| .add("boom.txt", "boom") |
| .message("blah") |
| .create(); |
| bu.addOp( |
| changeId, |
| patchSetInserterFactory |
| .create(notes, PatchSet.id(changeId, 2), commitId) |
| .setMessage("blah")); |
| ResourceConflictException thrown = assertThrows(ResourceConflictException.class, bu::execute); |
| assertThat(thrown) |
| .hasMessageThat() |
| .contains("Exceeding maximum number of files per change (3 > 2)"); |
| } |
| } |
| |
| @Test |
| public void limitFileCount_cacheKeyMatches() throws Exception { |
| Change.Id changeId = createChangeWithUpdates(1); |
| ChangeNotes notes = changeNotesFactory.create(project, changeId); |
| |
| int cacheSizeBefore = diffSummaryCache.asMap().size(); |
| |
| // We don't want to depend on the test helper used above so we perform an explicit commit |
| // here. |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| ObjectId commitId = |
| repo.amend(notes.getCurrentPatchSet().commitId()) |
| .add("bar.txt", "bar") |
| .add("baz.txt", "baz") |
| .message("blah") |
| .create(); |
| bu.addOp( |
| changeId, |
| patchSetInserterFactory |
| .create(notes, PatchSet.id(changeId, 3), commitId) |
| .setMessage("blah")); |
| bu.execute(); |
| } |
| |
| // Assert that we only performed the diff computation once. This would e.g. catch |
| // bugs/deviations in the computation of the cache key. |
| assertThat(diffSummaryCache.asMap()).hasSize(cacheSizeBefore + 1); |
| } |
| |
| @Test |
| public void executeOpsWithDifferentUsers() throws Exception { |
| Change.Id changeId = createChange(); |
| |
| ObjectId oldHead = getMetaId(changeId); |
| |
| CurrentUser defaultUser = user.get(); |
| IdentifiedUser user1 = |
| userFactory.create( |
| accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId()); |
| IdentifiedUser user2 = |
| userFactory.create( |
| accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId()); |
| |
| TestOp testOp1 = new TestOp().addReviewer(defaultUser.getAccountId()); |
| TestOp testOp2 = new TestOp().addReviewer(user1.getAccountId()); |
| TestOp testOp3 = new TestOp().addReviewer(user2.getAccountId()); |
| |
| try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) { |
| bu.addOp(changeId, user1, testOp1); |
| bu.addOp(changeId, user2, testOp2); |
| bu.addOp(changeId, testOp3); |
| bu.execute(); |
| |
| PersonIdent refLogIdent = bu.getRefLogIdent().get(); |
| assertThat(refLogIdent.getName()) |
| .isEqualTo( |
| String.format( |
| "account-%s|account-%s|account-%s", |
| defaultUser.asIdentifiedUser().getAccountId(), |
| user1.asIdentifiedUser().getAccountId(), |
| user2.asIdentifiedUser().getAccountId())); |
| assertThat(refLogIdent.getEmailAddress()) |
| .isEqualTo(String.format("%s@unknown", refLogIdent.getName())); |
| } |
| |
| assertThat(testOp1.updateRepoUser).isEqualTo(user1); |
| assertThat(testOp1.updateChangeUser).isEqualTo(user1); |
| assertThat(testOp1.postUpdateUser).isEqualTo(user1); |
| |
| assertThat(testOp2.updateRepoUser).isEqualTo(user2); |
| assertThat(testOp2.updateChangeUser).isEqualTo(user2); |
| assertThat(testOp2.postUpdateUser).isEqualTo(user2); |
| |
| assertThat(testOp3.updateRepoUser).isEqualTo(defaultUser); |
| assertThat(testOp3.updateChangeUser).isEqualTo(defaultUser); |
| assertThat(testOp3.postUpdateUser).isEqualTo(defaultUser); |
| |
| // Verify that we got one meta commit per op. |
| RevCommit metaCommitForTestOp3 = repo.getRepository().parseCommit(getMetaId(changeId)); |
| assertThat(metaCommitForTestOp3.getAuthorIdent().getEmailAddress()) |
| .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId())); |
| assertThat(metaCommitForTestOp3.getFullMessage()) |
| .startsWith( |
| "Update patch set 1\n" |
| + "\n" |
| + "Patch-set: 1\n" |
| + String.format( |
| "Reviewer: Gerrit User %s <%s@gerrit>\n", |
| user2.getAccountId(), user2.getAccountId()) |
| + "Attention:"); |
| |
| RevCommit metaCommitForTestOp2 = |
| repo.getRepository().parseCommit(metaCommitForTestOp3.getParent(0)); |
| assertThat(metaCommitForTestOp2.getAuthorIdent().getEmailAddress()) |
| .isEqualTo(String.format("%s@gerrit", user2.getAccountId())); |
| assertThat(metaCommitForTestOp2.getFullMessage()) |
| .startsWith( |
| "Update patch set 1\n" |
| + "\n" |
| + "Patch-set: 1\n" |
| + String.format( |
| "Reviewer: Gerrit User %s <%s@gerrit>\n", |
| user1.getAccountId(), user1.getAccountId()) |
| + "Attention:"); |
| |
| RevCommit metaCommitForTestOp1 = |
| repo.getRepository().parseCommit(metaCommitForTestOp2.getParent(0)); |
| assertThat(metaCommitForTestOp1.getAuthorIdent().getEmailAddress()) |
| .isEqualTo(String.format("%s@gerrit", user1.getAccountId())); |
| assertThat(metaCommitForTestOp1.getFullMessage()) |
| .isEqualTo( |
| "Update patch set 1\n" |
| + "\n" |
| + "Patch-set: 1\n" |
| + String.format( |
| "Reviewer: Gerrit User %s <%s@gerrit>\n", |
| defaultUser.getAccountId(), defaultUser.getAccountId())); |
| |
| assertThat(metaCommitForTestOp1.getParent(0)).isEqualTo(oldHead); |
| } |
| |
| @Test |
| public void executeOpsWithSameUser() throws Exception { |
| Change.Id changeId = createChange(); |
| |
| ObjectId oldHead = getMetaId(changeId); |
| |
| CurrentUser defaultUser = user.get(); |
| IdentifiedUser user1 = |
| userFactory.create( |
| accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId()); |
| IdentifiedUser user2 = |
| userFactory.create( |
| accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId()); |
| |
| TestOp testOp1 = new TestOp().addReviewer(user1.getAccountId()); |
| TestOp testOp2 = new TestOp().addReviewer(user2.getAccountId()); |
| |
| try (BatchUpdate bu = batchUpdateFactory.create(project, defaultUser, TimeUtil.now())) { |
| bu.addOp(changeId, defaultUser, testOp1); |
| bu.addOp(changeId, testOp2); |
| bu.execute(); |
| |
| PersonIdent refLogIdent = bu.getRefLogIdent().get(); |
| PersonIdent defaultUserRefLogIdent = defaultUser.asIdentifiedUser().newRefLogIdent(); |
| assertThat(refLogIdent.getName()).isEqualTo(defaultUserRefLogIdent.getName()); |
| assertThat(refLogIdent.getEmailAddress()).isEqualTo(defaultUserRefLogIdent.getEmailAddress()); |
| } |
| |
| assertThat(testOp1.updateRepoUser).isEqualTo(defaultUser); |
| assertThat(testOp1.updateChangeUser).isEqualTo(defaultUser); |
| assertThat(testOp1.postUpdateUser).isEqualTo(defaultUser); |
| |
| assertThat(testOp2.updateRepoUser).isEqualTo(defaultUser); |
| assertThat(testOp2.updateChangeUser).isEqualTo(defaultUser); |
| assertThat(testOp2.postUpdateUser).isEqualTo(defaultUser); |
| |
| // Verify that we got a single meta commit (updates of both ops squashed into one commit). |
| RevCommit metaCommit = repo.getRepository().parseCommit(getMetaId(changeId)); |
| assertThat(metaCommit.getAuthorIdent().getEmailAddress()) |
| .isEqualTo(String.format("%s@gerrit", defaultUser.getAccountId())); |
| assertThat(metaCommit.getFullMessage()) |
| .startsWith( |
| "Update patch set 1\n" |
| + "\n" |
| + "Patch-set: 1\n" |
| + String.format( |
| "Reviewer: Gerrit User %s <%s@gerrit>\n", |
| user1.getAccountId(), user1.getAccountId()) |
| + String.format( |
| "Reviewer: Gerrit User %s <%s@gerrit>\n", |
| user2.getAccountId(), user2.getAccountId()) |
| + "Attention:"); |
| |
| assertThat(metaCommit.getParent(0)).isEqualTo(oldHead); |
| } |
| |
| @Test |
| public void lockFailureOnConcurrentUpdate() throws Exception { |
| Change.Id changeId = createChange(); |
| ObjectId metaId = getMetaId(changeId); |
| |
| ChangeNotes notes = changeNotesFactory.create(project, changeId); |
| assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW); |
| |
| AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false); |
| |
| // Create a listener that updates the change meta ref concurrently on the first attempt. |
| BatchUpdateListener listener = |
| new BatchUpdateListener() { |
| @Override |
| public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) { |
| try (RevWalk rw = new RevWalk(repo.getRepository())) { |
| RevCommit old = rw.parseCommit(metaId); |
| RevCommit commit = |
| repo.commit() |
| .parent(old) |
| .author(serverIdent) |
| .committer(serverIdent) |
| .setTopLevelTree(old.getTree()) |
| .message("Concurrent Update\n\nPatch-Set: 1") |
| .create(); |
| RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId)); |
| ru.setExpectedOldObjectId(metaId); |
| ru.setNewObjectId(commit); |
| ru.update(); |
| RefUpdateUtil.checkResult(ru); |
| doneBackgroundUpdate.set(true); |
| } catch (Exception e) { |
| // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later |
| // when doneBackgroundUpdate is checked. |
| } |
| return bru; |
| } |
| }; |
| |
| // Do a batch update, expect that it fails with LOCK_FAILURE due to the concurrent update. |
| assertThat(doneBackgroundUpdate.get()).isFalse(); |
| UpdateException exception = |
| assertThrows( |
| UpdateException.class, |
| () -> { |
| try (BatchUpdate bu = |
| batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(changeId, abandonOpFactory.create(null, "abandon")); |
| bu.execute(listener); |
| } |
| }); |
| assertThat(exception).hasCauseThat().isInstanceOf(LockFailureException.class); |
| assertThat(doneBackgroundUpdate.get()).isTrue(); |
| |
| // Check that the change was not updated. |
| notes = changeNotesFactory.create(project, changeId); |
| assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW); |
| } |
| |
| @Test |
| public void useRetryHelperToRetryOnLockFailure() throws Exception { |
| Change.Id changeId = createChange(); |
| ObjectId metaId = getMetaId(changeId); |
| |
| ChangeNotes notes = changeNotesFactory.create(project, changeId); |
| assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW); |
| |
| AtomicBoolean doneBackgroundUpdate = new AtomicBoolean(false); |
| |
| // Create a listener that updates the change meta ref concurrently on the first attempt. |
| BatchUpdateListener listener = |
| new BatchUpdateListener() { |
| @Override |
| public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) { |
| if (!doneBackgroundUpdate.getAndSet(true)) { |
| try (RevWalk rw = new RevWalk(repo.getRepository())) { |
| RevCommit old = rw.parseCommit(metaId); |
| RevCommit commit = |
| repo.commit() |
| .parent(old) |
| .author(serverIdent) |
| .committer(serverIdent) |
| .setTopLevelTree(old.getTree()) |
| .message("Concurrent Update\n\nPatch-Set: 1") |
| .create(); |
| RefUpdate ru = repo.getRepository().updateRef(RefNames.changeMetaRef(changeId)); |
| ru.setExpectedOldObjectId(metaId); |
| ru.setNewObjectId(commit); |
| ru.update(); |
| RefUpdateUtil.checkResult(ru); |
| } catch (Exception e) { |
| // Ignore. If an exception happens doneBackgroundUpdate is false and we fail later |
| // when doneBackgroundUpdate is checked. |
| } |
| } |
| return bru; |
| } |
| }; |
| |
| // Do a batch update, expect that it succeeds due to retrying despite the LOCK_FAILURE on the |
| // first attempt. |
| assertThat(doneBackgroundUpdate.get()).isFalse(); |
| |
| @SuppressWarnings("unused") |
| var unused = |
| retryHelper |
| .changeUpdate( |
| "batchUpdate", |
| updateFactory -> { |
| try (BatchUpdate bu = updateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(changeId, abandonOpFactory.create(null, "abandon")); |
| bu.execute(listener); |
| } |
| return null; |
| }) |
| .call(); |
| |
| // Check that the concurrent update was done. |
| assertThat(doneBackgroundUpdate.get()).isTrue(); |
| |
| // Check that the BatchUpdate updated the change. |
| notes = changeNotesFactory.create(project, changeId); |
| assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.ABANDONED); |
| } |
| |
| @Test |
| public void noRetryingOnOuterLevelIfRetryingWasAlreadyDoneOnInnerLevel() throws Exception { |
| Change.Id changeId = createChange(); |
| |
| ChangeNotes notes = changeNotesFactory.create(project, changeId); |
| assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW); |
| |
| AtomicBoolean backgroundFailure = new AtomicBoolean(false); |
| |
| // Create a listener that updates the change meta ref concurrently on all attempts. |
| BatchUpdateListener listener = |
| new BatchUpdateListener() { |
| @Override |
| public BatchRefUpdate beforeUpdateRefs(BatchRefUpdate bru) { |
| try (RevWalk rw = new RevWalk(repo.getRepository())) { |
| String changeMetaRef = RefNames.changeMetaRef(changeId); |
| ObjectId metaId = repo.getRepository().exactRef(changeMetaRef).getObjectId(); |
| RevCommit old = rw.parseCommit(metaId); |
| RevCommit commit = |
| repo.commit() |
| .parent(old) |
| .author(serverIdent) |
| .committer(serverIdent) |
| .setTopLevelTree(old.getTree()) |
| .message("Concurrent Update\n\nPatch-Set: 1") |
| .create(); |
| RefUpdate ru = repo.getRepository().updateRef(changeMetaRef); |
| ru.setExpectedOldObjectId(metaId); |
| ru.setNewObjectId(commit); |
| ru.update(); |
| RefUpdateUtil.checkResult(ru); |
| } catch (Exception e) { |
| backgroundFailure.set(true); |
| } |
| |
| return bru; |
| } |
| }; |
| |
| AtomicInteger innerRetryOnExceptionCounter = new AtomicInteger(); |
| AtomicInteger outerRetryOnExceptionCounter = new AtomicInteger(); |
| UpdateException exception = |
| assertThrows( |
| UpdateException.class, |
| () -> |
| // Outer level retrying. We expect that no retrying is happens here because retrying |
| // is already done on the inner level. |
| retryHelper |
| .action( |
| ActionType.CHANGE_UPDATE, |
| "batchUpdate", |
| () -> |
| // Inner level retrying. We expect that retrying happens here. |
| retryHelper |
| .changeUpdate( |
| "batchUpdate", |
| updateFactory -> { |
| try (BatchUpdate bu = |
| updateFactory.create( |
| project, user.get(), TimeUtil.now())) { |
| bu.addOp( |
| changeId, abandonOpFactory.create(null, "abandon")); |
| bu.execute(listener); |
| } |
| return null; |
| }) |
| .listener( |
| new RetryListener() { |
| @Override |
| public <V> void onRetry(Attempt<V> attempt) { |
| if (attempt.hasException()) { |
| @SuppressWarnings("unused") |
| var unused = |
| innerRetryOnExceptionCounter.incrementAndGet(); |
| } |
| } |
| }) |
| .call()) |
| // give it enough time to potentially retry multiple times when each retry also |
| // does retrying |
| .defaultTimeoutMultiplier(5) |
| .listener( |
| new RetryListener() { |
| @Override |
| public <V> void onRetry(Attempt<V> attempt) { |
| if (attempt.hasException()) { |
| @SuppressWarnings("unused") |
| var unused = outerRetryOnExceptionCounter.incrementAndGet(); |
| } |
| } |
| }) |
| .call()); |
| assertThat(backgroundFailure.get()).isFalse(); |
| |
| // Check that retrying was done on the inner level. |
| assertThat(innerRetryOnExceptionCounter.get()).isGreaterThan(1); |
| |
| // Check that there was no retrying on the outer level since retrying was already done on the |
| // inner level. |
| // We expect 1 because RetryListener#onRetry is invoked before the rejection predicate and stop |
| // strategies are applied (i.e. before RetryHelper decides whether retrying should be done). |
| assertThat(outerRetryOnExceptionCounter.get()).isEqualTo(1); |
| |
| assertThat(exception).hasCauseThat().isInstanceOf(RetryException.class); |
| assertThat(exception.getCause()).hasCauseThat().isInstanceOf(UpdateException.class); |
| assertThat(exception.getCause().getCause()) |
| .hasCauseThat() |
| .isInstanceOf(LockFailureException.class); |
| |
| // Check that the change was not updated. |
| notes = changeNotesFactory.create(project, changeId); |
| assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.NEW); |
| } |
| |
| private Change.Id createChange() throws Exception { |
| Change.Id id = Change.id(sequences.nextChangeId()); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.insertChange( |
| changeInserterFactory.create( |
| id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master")); |
| bu.execute(); |
| } |
| return id; |
| } |
| |
| private Change.Id createChangeWithUpdates(int totalUpdates) throws Exception { |
| checkArgument(totalUpdates > 0); |
| checkArgument(totalUpdates <= MAX_UPDATES); |
| Change.Id id = Change.id(sequences.nextChangeId()); |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.insertChange( |
| changeInserterFactory.create( |
| id, repo.commit().message("Change").insertChangeId().create(), "refs/heads/master")); |
| bu.execute(); |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(1); |
| for (int i = 2; i <= totalUpdates; i++) { |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| bu.addOp(id, new AddMessageOp("Update " + i)); |
| bu.execute(); |
| } |
| } |
| assertThat(getUpdateCount(id)).isEqualTo(totalUpdates); |
| return id; |
| } |
| |
| private Change.Id createChangeWithPatchSets(int patchSets) throws Exception { |
| checkArgument(patchSets >= 2); |
| Change.Id id = createChangeWithUpdates(MAX_UPDATES - 2); |
| ChangeNotes notes = changeNotesFactory.create(project, id); |
| for (int i = 2; i <= patchSets; ++i) { |
| try (BatchUpdate bu = batchUpdateFactory.create(project, user.get(), TimeUtil.now())) { |
| ObjectId commitId = |
| repo.amend(notes.getCurrentPatchSet().commitId()).message("PS" + i).create(); |
| bu.addOp( |
| id, |
| patchSetInserterFactory |
| .create(notes, PatchSet.id(id, i), commitId) |
| .setMessage("Add PS" + i)); |
| bu.execute(); |
| } |
| } |
| return id; |
| } |
| |
| private static class AddMessageOp implements BatchUpdateOp { |
| private final String message; |
| @Nullable private final PatchSet.Id psId; |
| |
| AddMessageOp(String message) { |
| this(message, null); |
| } |
| |
| AddMessageOp(String message, PatchSet.Id psId) { |
| this.message = message; |
| this.psId = psId; |
| } |
| |
| @Override |
| public boolean updateChange(ChangeContext ctx) throws Exception { |
| PatchSet.Id psIdToUpdate = psId; |
| if (psIdToUpdate == null) { |
| psIdToUpdate = ctx.getChange().currentPatchSetId(); |
| } else { |
| checkState( |
| ctx.getNotes().getPatchSets().containsKey(psIdToUpdate), |
| "%s not in %s", |
| psIdToUpdate, |
| ctx.getNotes().getPatchSets().keySet()); |
| } |
| ctx.getUpdate(psIdToUpdate).setChangeMessage(message); |
| return true; |
| } |
| } |
| |
| private int getUpdateCount(Change.Id changeId) { |
| return changeNotesFactory.create(project, changeId).getUpdateCount(); |
| } |
| |
| private ObjectId getMetaId(Change.Id changeId) throws Exception { |
| return repo.getRepository().exactRef(RefNames.changeMetaRef(changeId)).getObjectId(); |
| } |
| |
| private static class SubmitOp implements BatchUpdateOp { |
| @Override |
| public boolean updateChange(ChangeContext ctx) throws Exception { |
| SubmitRecord sr = new SubmitRecord(); |
| sr.status = SubmitRecord.Status.OK; |
| SubmitRecord.Label cr = new SubmitRecord.Label(); |
| cr.status = SubmitRecord.Label.Status.OK; |
| cr.appliedBy = ctx.getAccountId(); |
| cr.label = "Code-Review"; |
| sr.labels = ImmutableList.of(cr); |
| ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId()); |
| update.merge(new SubmissionId(ctx.getChange()), ImmutableList.of(sr)); |
| update.setChangeMessage("Submitted"); |
| return true; |
| } |
| } |
| |
| private static class TestOp implements BatchUpdateOp { |
| CurrentUser updateRepoUser; |
| CurrentUser updateChangeUser; |
| CurrentUser postUpdateUser; |
| |
| private List<Account.Id> reviewersToAdd = new ArrayList<>(); |
| |
| TestOp addReviewer(Account.Id accountId) { |
| reviewersToAdd.add(accountId); |
| return this; |
| } |
| |
| @Override |
| public void updateRepo(RepoContext ctx) { |
| updateRepoUser = ctx.getUser(); |
| } |
| |
| @Override |
| public boolean updateChange(ChangeContext ctx) { |
| updateChangeUser = ctx.getUser(); |
| |
| reviewersToAdd.forEach( |
| accountId -> |
| ctx.getUpdate(ctx.getChange().currentPatchSetId()) |
| .putReviewer(accountId, ReviewerStateInternal.REVIEWER)); |
| return true; |
| } |
| |
| @Override |
| public void postUpdate(PostUpdateContext ctx) { |
| postUpdateUser = ctx.getUser(); |
| } |
| } |
| } |