blob: 01f3d19cc8b1e394d77264179971a0088322663e [file] [log] [blame]
// Copyright (C) 2016 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.acceptance.server.notedb;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assert_;
import static com.google.common.truth.Truth8.assertThat;
import static com.google.common.truth.TruthJUnit.assume;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.formatTime;
import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.toList;
import com.github.rholder.retry.Retryer;
import com.github.rholder.retry.RetryerBuilder;
import com.github.rholder.retry.StopStrategies;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.NoHttpd;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
import com.google.gerrit.extensions.client.ChangeStatus;
import com.google.gerrit.extensions.common.ApprovalInfo;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.common.CommentInfo;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.InternalUser;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.git.RepoRefCache;
import com.google.gerrit.server.notedb.ChangeBundle;
import com.google.gerrit.server.notedb.ChangeBundleReader;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.notedb.NoteDbChangeState;
import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
import com.google.gerrit.server.update.RetryHelper;
import com.google.gerrit.testutil.ConfigSuite;
import com.google.gerrit.testutil.NoteDbMode;
import com.google.gerrit.testutil.TestTimeUtil;
import com.google.gwtorm.server.OrmException;
import com.google.gwtorm.server.OrmRuntimeException;
import com.google.inject.Inject;
import com.google.inject.util.Providers;
import java.sql.Timestamp;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.ObjectId;
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.Test;
@NoHttpd
public class NoteDbPrimaryIT extends AbstractDaemonTest {
@ConfigSuite.Default
public static Config defaultConfig() {
Config cfg = new Config();
cfg.setString("notedb", null, "concurrentWriterTimeout", "0s");
cfg.setString("notedb", null, "primaryStorageMigrationTimeout", "1d");
cfg.setBoolean("noteDb", null, "testRebuilderWrapper", true);
return cfg;
}
@Inject private AllUsersName allUsers;
@Inject private ChangeBundleReader bundleReader;
@Inject private CommentsUtil commentsUtil;
@Inject private TestChangeRebuilderWrapper rebuilderWrapper;
@Inject private ChangeNotes.Factory changeNotesFactory;
@Inject private ChangeUpdate.Factory updateFactory;
@Inject private InternalUser.Factory internalUserFactory;
@Inject private RetryHelper retryHelper;
private PrimaryStorageMigrator migrator;
@Before
public void setUp() throws Exception {
assume().that(NoteDbMode.get()).isEqualTo(NoteDbMode.READ_WRITE);
db = ReviewDbUtil.unwrapDb(db);
TestTimeUtil.resetWithClockStep(1, SECONDS);
migrator = newMigrator(null);
}
private PrimaryStorageMigrator newMigrator(
@Nullable Retryer<NoteDbChangeState> ensureRebuiltRetryer) {
return new PrimaryStorageMigrator(
cfg,
Providers.of(db),
repoManager,
allUsers,
rebuilderWrapper,
ensureRebuiltRetryer,
changeNotesFactory,
queryProvider,
updateFactory,
internalUserFactory,
retryHelper);
}
@After
public void tearDown() {
TestTimeUtil.useSystemTime();
}
@Test
public void updateChange() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
setNoteDbPrimary(id);
gApi.changes().id(id.get()).current().review(ReviewInput.approve());
gApi.changes().id(id.get()).current().submit();
ChangeInfo info = gApi.changes().id(id.get()).get();
assertThat(info.status).isEqualTo(ChangeStatus.MERGED);
ApprovalInfo approval = Iterables.getOnlyElement(info.labels.get("Code-Review").all);
assertThat(approval._accountId).isEqualTo(admin.id.get());
assertThat(approval.value).isEqualTo(2);
assertThat(info.messages).hasSize(3);
assertThat(Iterables.getLast(info.messages).message)
.isEqualTo("Change has been successfully merged by " + admin.fullName);
ChangeNotes notes = notesFactory.create(db, project, id);
assertThat(notes.getChange().getStatus()).isEqualTo(Change.Status.MERGED);
assertThat(notes.getChange().getNoteDbState())
.isEqualTo(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
// Writes weren't reflected in ReviewDb.
assertThat(db.changes().get(id).getStatus()).isEqualTo(Change.Status.NEW);
assertThat(db.patchSetApprovals().byChange(id)).isEmpty();
assertThat(db.changeMessages().byChange(id)).hasSize(1);
}
@Test
public void deleteDraftComment() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
setNoteDbPrimary(id);
DraftInput din = new DraftInput();
din.path = PushOneCommit.FILE_NAME;
din.line = 1;
din.message = "A comment";
gApi.changes().id(id.get()).current().createDraft(din);
CommentInfo di =
Iterables.getOnlyElement(
gApi.changes().id(id.get()).current().drafts().get(PushOneCommit.FILE_NAME));
assertThat(di.message).isEqualTo(din.message);
assertThat(db.patchComments().draftByChangeFileAuthor(id, din.path, admin.id)).isEmpty();
gApi.changes().id(id.get()).current().draft(di.id).delete();
assertThat(gApi.changes().id(id.get()).current().drafts()).isEmpty();
}
@Test
public void deleteVote() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
setNoteDbPrimary(id);
gApi.changes().id(id.get()).current().review(ReviewInput.approve());
List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
assertThat(approvals).hasSize(1);
assertThat(approvals.get(0).value).isEqualTo(2);
gApi.changes().id(id.get()).reviewer(admin.id.toString()).deleteVote("Code-Review");
approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
assertThat(approvals).hasSize(1);
assertThat(approvals.get(0).value).isEqualTo(0);
}
@Test
public void deleteVoteViaReview() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
setNoteDbPrimary(id);
gApi.changes().id(id.get()).current().review(ReviewInput.approve());
List<ApprovalInfo> approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
assertThat(approvals).hasSize(1);
assertThat(approvals.get(0).value).isEqualTo(2);
gApi.changes().id(id.get()).current().review(ReviewInput.noScore());
approvals = gApi.changes().id(id.get()).get().labels.get("Code-Review").all;
assertThat(approvals).hasSize(1);
assertThat(approvals.get(0).value).isEqualTo(0);
}
@Test
public void deleteReviewer() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
setNoteDbPrimary(id);
gApi.changes().id(id.get()).addReviewer(user.id.toString());
assertThat(getReviewers(id)).containsExactly(user.id);
gApi.changes().id(id.get()).reviewer(user.id.toString()).remove();
assertThat(getReviewers(id)).isEmpty();
}
@Test
public void readOnlyReviewDb() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
testReadOnly(id);
}
@Test
public void readOnlyNoteDb() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
setNoteDbPrimary(id);
testReadOnly(id);
}
private void testReadOnly(Change.Id id) throws Exception {
Timestamp before = TimeUtil.nowTs();
Timestamp until = new Timestamp(before.getTime() + 1000 * 3600);
// Set read-only.
Change c = db.changes().get(id);
assertThat(c).named("change " + id).isNotNull();
NoteDbChangeState state = NoteDbChangeState.parse(c);
state = state.withReadOnlyUntil(until);
c.setNoteDbState(state.toString());
db.changes().update(Collections.singleton(c));
assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
assertThat(gApi.changes().id(id.get()).get().topic).isNull();
try {
gApi.changes().id(id.get()).topic("a-topic");
assert_().fail("expected read-only exception");
} catch (RestApiException e) {
Optional<Throwable> oe =
Throwables.getCausalChain(e).stream()
.filter(x -> x instanceof OrmRuntimeException)
.findFirst();
assertThat(oe).named("OrmRuntimeException in causal chain of " + e).isPresent();
assertThat(oe.get().getMessage()).contains("read-only");
}
assertThat(gApi.changes().id(id.get()).get().topic).isNull();
TestTimeUtil.setClock(new Timestamp(until.getTime() + 1000));
assertThat(gApi.changes().id(id.get()).get().subject).isEqualTo(PushOneCommit.SUBJECT);
gApi.changes().id(id.get()).topic("a-topic");
assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
}
@Test
public void migrateToNoteDb() throws Exception {
testMigrateToNoteDb(createChange().getChange().getId());
}
@Test
public void migrateToNoteDbWithRebuildingFirst() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
Change c = db.changes().get(id);
c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
db.changes().update(Collections.singleton(c));
testMigrateToNoteDb(id);
}
private void testMigrateToNoteDb(Change.Id id) throws Exception {
assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
migrator.migrateToNoteDbPrimary(id);
assertNoteDbPrimary(id);
gApi.changes().id(id.get()).topic("a-topic");
assertThat(gApi.changes().id(id.get()).get().topic).isEqualTo("a-topic");
assertThat(db.changes().get(id).getTopic()).isNull();
}
@Test
public void migrateToNoteDbFailsRebuildingOnceAndRetries() throws Exception {
Change.Id id = createChange().getChange().getId();
Change c = db.changes().get(id);
c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
db.changes().update(Collections.singleton(c));
rebuilderWrapper.failNextUpdate();
migrator =
newMigrator(
RetryerBuilder.<NoteDbChangeState>newBuilder()
.retryIfException()
.withStopStrategy(StopStrategies.neverStop())
.build());
migrator.migrateToNoteDbPrimary(id);
assertNoteDbPrimary(id);
}
@Test
public void migrateToNoteDbFailsRebuildingAndStops() throws Exception {
Change.Id id = createChange().getChange().getId();
Change c = db.changes().get(id);
c.setNoteDbState("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef");
db.changes().update(Collections.singleton(c));
rebuilderWrapper.failNextUpdate();
migrator =
newMigrator(
RetryerBuilder.<NoteDbChangeState>newBuilder()
.retryIfException()
.withStopStrategy(StopStrategies.stopAfterAttempt(1))
.build());
exception.expect(OrmException.class);
exception.expectMessage("Retrying failed");
migrator.migrateToNoteDbPrimary(id);
}
@Test
public void migrateToNoteDbMissingOldState() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
Change c = db.changes().get(id);
c.setNoteDbState(null);
db.changes().update(Collections.singleton(c));
exception.expect(OrmRuntimeException.class);
exception.expectMessage("no note_db_state");
migrator.migrateToNoteDbPrimary(id);
}
@Test
public void migrateToNoteDbLeaseExpires() throws Exception {
TestTimeUtil.resetWithClockStep(2, DAYS);
exception.expect(OrmRuntimeException.class);
exception.expectMessage("read-only lease");
migrator.migrateToNoteDbPrimary(createChange().getChange().getId());
}
@Test
public void migrateToNoteDbAlreadyReadOnly() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
Change c = db.changes().get(id);
NoteDbChangeState state = NoteDbChangeState.parse(c);
Timestamp until = new Timestamp(TimeUtil.nowMs() + MILLISECONDS.convert(1, DAYS));
state = state.withReadOnlyUntil(until);
c.setNoteDbState(state.toString());
db.changes().update(Collections.singleton(c));
exception.expect(OrmRuntimeException.class);
exception.expectMessage("read-only until " + until);
migrator.migrateToNoteDbPrimary(id);
}
@Test
public void migrateToNoteDbAlreadyMigrated() throws Exception {
PushOneCommit.Result r = createChange();
Change.Id id = r.getChange().getId();
assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.REVIEW_DB);
migrator.migrateToNoteDbPrimary(id);
assertNoteDbPrimary(id);
migrator.migrateToNoteDbPrimary(id);
assertNoteDbPrimary(id);
}
@Test
public void rebuildReviewDb() throws Exception {
Change c = createChange().getChange().change();
Change.Id id = c.getId();
CommentInput cin = new CommentInput();
cin.line = 1;
cin.message = "Published comment";
ReviewInput rin = ReviewInput.approve();
rin.comments = ImmutableMap.of(PushOneCommit.FILE_NAME, ImmutableList.of(cin));
gApi.changes().id(id.get()).current().review(ReviewInput.approve());
DraftInput din = new DraftInput();
din.path = PushOneCommit.FILE_NAME;
din.line = 1;
din.message = "Draft comment";
gApi.changes().id(id.get()).current().createDraft(din);
gApi.changes().id(id.get()).current().review(ReviewInput.approve());
gApi.changes().id(id.get()).current().createDraft(din);
assertThat(db.changeMessages().byChange(id)).isNotEmpty();
assertThat(db.patchSets().byChange(id)).isNotEmpty();
assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
assertThat(db.patchComments().byChange(id)).isNotEmpty();
ChangeBundle noteDbBundle =
ChangeBundle.fromNotes(commentsUtil, notesFactory.create(db, project, id));
setNoteDbPrimary(id);
db.changeMessages().delete(db.changeMessages().byChange(id));
db.patchSets().delete(db.patchSets().byChange(id));
db.patchSetApprovals().delete(db.patchSetApprovals().byChange(id));
db.patchComments().delete(db.patchComments().byChange(id));
ChangeMessage bogusMessage =
ChangeMessagesUtil.newMessage(
c.currentPatchSetId(),
identifiedUserFactory.create(admin.getId()),
TimeUtil.nowTs(),
"some message",
null);
db.changeMessages().insert(Collections.singleton(bogusMessage));
rebuilderWrapper.rebuildReviewDb(db, project, id);
assertThat(db.changeMessages().byChange(id)).isNotEmpty();
assertThat(db.patchSets().byChange(id)).isNotEmpty();
assertThat(db.patchSetApprovals().byChange(id)).isNotEmpty();
assertThat(db.patchComments().byChange(id)).isNotEmpty();
ChangeBundle reviewDbBundle = bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db), id);
assertThat(reviewDbBundle.differencesFrom(noteDbBundle)).isEmpty();
}
@Test
public void rebuildReviewDbRequiresNoteDbPrimary() throws Exception {
Change.Id id = createChange().getChange().getId();
exception.expect(OrmException.class);
exception.expectMessage("primary storage of " + id + " is REVIEW_DB");
rebuilderWrapper.rebuildReviewDb(db, project, id);
}
@Test
public void migrateBackToReviewDbPrimary() throws Exception {
Change c = createChange().getChange().change();
Change.Id id = c.getId();
migrator.migrateToNoteDbPrimary(id);
assertNoteDbPrimary(id);
gApi.changes().id(id.get()).topic("new-topic");
assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
assertThat(db.changes().get(id).getTopic()).isNotEqualTo("new-topic");
migrator.migrateToReviewDbPrimary(id, null);
ObjectId metaId;
try (Repository repo = repoManager.openRepository(c.getProject());
RevWalk rw = new RevWalk(repo)) {
metaId = repo.exactRef(RefNames.changeMetaRef(id)).getObjectId();
RevCommit commit = rw.parseCommit(metaId);
rw.parseBody(commit);
assertThat(commit.getFullMessage())
.contains("Read-only-until: " + formatTime(serverIdent.get(), new Timestamp(0)));
}
NoteDbChangeState state = NoteDbChangeState.parse(db.changes().get(id));
assertThat(state.getPrimaryStorage()).isEqualTo(PrimaryStorage.REVIEW_DB);
assertThat(state.getChangeMetaId()).isEqualTo(metaId);
assertThat(gApi.changes().id(id.get()).topic()).isEqualTo("new-topic");
assertThat(db.changes().get(id).getTopic()).isEqualTo("new-topic");
ChangeNotes notes = notesFactory.create(db, project, id);
assertThat(notes.getRevision()).isEqualTo(metaId); // No rebuilding, change was up to date.
assertThat(notes.getReadOnlyUntil()).isNotNull();
gApi.changes().id(id.get()).topic("reviewdb-topic");
assertThat(db.changes().get(id).getTopic()).isEqualTo("reviewdb-topic");
}
private void setNoteDbPrimary(Change.Id id) throws Exception {
Change c = db.changes().get(id);
assertThat(c).named("change " + id).isNotNull();
NoteDbChangeState state = NoteDbChangeState.parse(c);
assertThat(state.getPrimaryStorage()).named("storage of " + id).isEqualTo(REVIEW_DB);
try (Repository changeRepo = repoManager.openRepository(c.getProject());
Repository allUsersRepo = repoManager.openRepository(allUsers)) {
assertThat(state.isUpToDate(new RepoRefCache(changeRepo), new RepoRefCache(allUsersRepo)))
.named("change " + id + " up to date")
.isTrue();
}
c.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
db.changes().update(Collections.singleton(c));
}
private void assertNoteDbPrimary(Change.Id id) throws Exception {
assertThat(PrimaryStorage.of(db.changes().get(id))).isEqualTo(PrimaryStorage.NOTE_DB);
}
private List<Account.Id> getReviewers(Change.Id id) throws Exception {
return gApi.changes().id(id.get()).get().reviewers.values().stream()
.flatMap(Collection::stream)
.map(a -> new Account.Id(a._accountId))
.collect(toList());
}
}