Support read-only mode for NoteDb changes
Offline or batch programs might want to read NoteDb changes from the repo but without any chance of writing to the repo and incurring contention. Support this by allowing NotesMigration to specify readChanges() = true and writeChanges() = false, which was previously disallowed. Now the behavior of writeChanges() = false is either a no-op or a hard failure, depending on whether reading is enabled. Change-Id: I3255a3380c50dcc3e52028f146b1d36d83bfdd41
This commit is contained in:
@@ -20,6 +20,7 @@ import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
|
||||
import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import com.google.common.base.Function;
|
||||
import com.google.common.collect.ImmutableList;
|
||||
@@ -34,6 +35,7 @@ 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.restapi.ResourceNotFoundException;
|
||||
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;
|
||||
@@ -52,9 +54,11 @@ import com.google.gerrit.server.config.AllUsersName;
|
||||
import com.google.gerrit.server.git.BatchUpdate;
|
||||
import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
|
||||
import com.google.gerrit.server.git.RepoRefCache;
|
||||
import com.google.gerrit.server.git.UpdateException;
|
||||
import com.google.gerrit.server.notedb.ChangeBundle;
|
||||
import com.google.gerrit.server.notedb.ChangeNotes;
|
||||
import com.google.gerrit.server.notedb.NoteDbChangeState;
|
||||
import com.google.gerrit.server.notedb.NoteDbUpdateManager;
|
||||
import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
|
||||
import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
|
||||
import com.google.gerrit.testutil.ConfigSuite;
|
||||
@@ -880,6 +884,71 @@ public class ChangeRebuilderIT extends AbstractDaemonTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failWhenWritesDisabled() throws Exception {
|
||||
setNotesMigration(true, true);
|
||||
|
||||
PushOneCommit.Result r = createChange();
|
||||
Change.Id id = r.getPatchSetId().getParentKey();
|
||||
assertChangeUpToDate(true, id);
|
||||
assertThat(gApi.changes().id(id.get()).info().topic).isNull();
|
||||
|
||||
// Turning off writes causes failure.
|
||||
setNotesMigration(false, true);
|
||||
try {
|
||||
gApi.changes().id(id.get()).topic(name("a-topic"));
|
||||
fail("Expected write to fail");
|
||||
} catch (RestApiException e) {
|
||||
assertChangesReadOnly(e);
|
||||
}
|
||||
|
||||
// Update was not written.
|
||||
assertThat(gApi.changes().id(id.get()).info().topic).isNull();
|
||||
assertChangeUpToDate(true, id);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rebuildWhenWritesDisabledWorksButDoesNotWrite() throws Exception {
|
||||
setNotesMigration(true, true);
|
||||
|
||||
PushOneCommit.Result r = createChange();
|
||||
Change.Id id = r.getPatchSetId().getParentKey();
|
||||
assertChangeUpToDate(true, id);
|
||||
|
||||
// Make a ReviewDb change behind NoteDb's back and ensure it's detected.
|
||||
setNotesMigration(false, false);
|
||||
gApi.changes().id(id.get()).topic(name("a-topic"));
|
||||
setInvalidNoteDbState(id);
|
||||
assertChangeUpToDate(false, id);
|
||||
|
||||
// On next NoteDb read, change is rebuilt in-memory but not stored.
|
||||
setNotesMigration(false, true);
|
||||
assertThat(gApi.changes().id(id.get()).info().topic)
|
||||
.isEqualTo(name("a-topic"));
|
||||
assertChangeUpToDate(false, id);
|
||||
|
||||
// Attempting to write directly causes failure.
|
||||
try {
|
||||
gApi.changes().id(id.get()).topic(name("other-topic"));
|
||||
fail("Expected write to fail");
|
||||
} catch (RestApiException e) {
|
||||
assertChangesReadOnly(e);
|
||||
}
|
||||
|
||||
// Update was not written.
|
||||
assertThat(gApi.changes().id(id.get()).info().topic)
|
||||
.isEqualTo(name("a-topic"));
|
||||
assertChangeUpToDate(false, id);
|
||||
}
|
||||
|
||||
private void assertChangesReadOnly(RestApiException e) throws Exception {
|
||||
Throwable cause = e.getCause();
|
||||
assertThat(cause).isInstanceOf(UpdateException.class);
|
||||
assertThat(cause.getCause()).isInstanceOf(OrmException.class);
|
||||
assertThat(cause.getCause())
|
||||
.hasMessage(NoteDbUpdateManager.CHANGES_READ_ONLY);
|
||||
}
|
||||
|
||||
private void setInvalidNoteDbState(Change.Id id) throws Exception {
|
||||
ReviewDb db = unwrapDb();
|
||||
Change c = db.changes().get(id);
|
||||
|
||||
@@ -54,7 +54,7 @@ public class Rebuild implements RestModifyView<ChangeResource, Input> {
|
||||
public Response<?> apply(ChangeResource rsrc, Input input)
|
||||
throws ResourceNotFoundException, IOException, OrmException,
|
||||
ConfigInvalidException {
|
||||
if (!migration.writeChanges()) {
|
||||
if (!migration.commitChangeWrites()) {
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -636,6 +636,11 @@ public class BatchUpdate implements AutoCloseable {
|
||||
|
||||
List<ChangeTask> tasks = new ArrayList<>(ops.keySet().size());
|
||||
try {
|
||||
if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
|
||||
// Fail fast before attempting any writes if changes are read-only, as
|
||||
// this is a programmer error.
|
||||
throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
|
||||
}
|
||||
List<ListenableFuture<?>> futures = new ArrayList<>(ops.keySet().size());
|
||||
for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
|
||||
ChangeTask task =
|
||||
@@ -645,13 +650,15 @@ public class BatchUpdate implements AutoCloseable {
|
||||
}
|
||||
Futures.allAsList(futures).get();
|
||||
|
||||
if (notesMigration.writeChanges()) {
|
||||
if (notesMigration.commitChangeWrites()) {
|
||||
executeNoteDbUpdates(tasks);
|
||||
}
|
||||
} catch (ExecutionException | InterruptedException e) {
|
||||
Throwables.propagateIfInstanceOf(e.getCause(), UpdateException.class);
|
||||
Throwables.propagateIfInstanceOf(e.getCause(), RestApiException.class);
|
||||
throw new UpdateException(e);
|
||||
} catch (OrmException e) {
|
||||
throw new UpdateException(e);
|
||||
}
|
||||
|
||||
// Reindex changes.
|
||||
@@ -802,7 +809,7 @@ public class BatchUpdate implements AutoCloseable {
|
||||
deleted = ctx.deleted;
|
||||
|
||||
// Stage the NoteDb update and store its state in the Change.
|
||||
if (notesMigration.writeChanges()) {
|
||||
if (notesMigration.commitChangeWrites()) {
|
||||
updateManager = stageNoteDbUpdate(ctx, deleted);
|
||||
}
|
||||
|
||||
@@ -821,7 +828,7 @@ public class BatchUpdate implements AutoCloseable {
|
||||
db.rollback();
|
||||
}
|
||||
|
||||
if (notesMigration.writeChanges()) {
|
||||
if (notesMigration.commitChangeWrites()) {
|
||||
try {
|
||||
// Do not execute the NoteDbUpdateManager, as we don't want too much
|
||||
// contention on the underlying repo, and we would rather use a
|
||||
|
||||
@@ -204,9 +204,7 @@ public class ChangeIndexer {
|
||||
public void index(ReviewDb db, Change change)
|
||||
throws IOException, OrmException {
|
||||
ChangeData cd;
|
||||
if (notesMigration.readChanges()) {
|
||||
cd = changeDataFactory.create(db, change);
|
||||
} else if (notesMigration.writeChanges()) {
|
||||
if (notesMigration.commitChangeWrites()) {
|
||||
// Auto-rebuilding when NoteDb reads are disabled just increases
|
||||
// contention on the meta ref from a background indexing thread with
|
||||
// little benefit. The next actual write to the entity may still incur a
|
||||
|
||||
@@ -192,6 +192,11 @@ public abstract class AbstractChangeUpdate {
|
||||
if (isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Allow this method to proceed even if migration.failChangeWrites() = true.
|
||||
// This may be used by an auto-rebuilding step that the caller does not plan
|
||||
// to actually store.
|
||||
|
||||
checkArgument(rw.getObjectReader().getCreatedFromInserter() == ins);
|
||||
ObjectId z = ObjectId.zeroId();
|
||||
CommitBuilder cb = applyImpl(rw, ins, curr);
|
||||
|
||||
@@ -122,6 +122,7 @@ public class ChangeRebuilderImpl extends ChangeRebuilder {
|
||||
private final ChangeNoteUtil changeNoteUtil;
|
||||
private final ChangeUpdate.Factory updateFactory;
|
||||
private final NoteDbUpdateManager.Factory updateManagerFactory;
|
||||
private final NotesMigration migration;
|
||||
private final PatchListCache patchListCache;
|
||||
private final PersonIdent serverIdent;
|
||||
private final ProjectCache projectCache;
|
||||
@@ -134,6 +135,7 @@ public class ChangeRebuilderImpl extends ChangeRebuilder {
|
||||
ChangeNoteUtil changeNoteUtil,
|
||||
ChangeUpdate.Factory updateFactory,
|
||||
NoteDbUpdateManager.Factory updateManagerFactory,
|
||||
NotesMigration migration,
|
||||
PatchListCache patchListCache,
|
||||
@GerritPersonIdent PersonIdent serverIdent,
|
||||
@Nullable ProjectCache projectCache,
|
||||
@@ -144,6 +146,7 @@ public class ChangeRebuilderImpl extends ChangeRebuilder {
|
||||
this.changeNoteUtil = changeNoteUtil;
|
||||
this.updateFactory = updateFactory;
|
||||
this.updateManagerFactory = updateManagerFactory;
|
||||
this.migration = migration;
|
||||
this.patchListCache = patchListCache;
|
||||
this.serverIdent = serverIdent;
|
||||
this.projectCache = projectCache;
|
||||
@@ -221,7 +224,14 @@ public class ChangeRebuilderImpl extends ChangeRebuilder {
|
||||
return change;
|
||||
}
|
||||
});
|
||||
if (!migration.failChangeWrites()) {
|
||||
manager.execute();
|
||||
} else {
|
||||
// Don't even attempt to execute if read-only, it would fail anyway. But
|
||||
// do throw an exception to the caller so they know to use the staged
|
||||
// results instead of reading from the repo.
|
||||
throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
|
||||
}
|
||||
} catch (AbortUpdateException e) {
|
||||
// Drop this rebuild; another thread completed it.
|
||||
}
|
||||
|
||||
@@ -64,10 +64,6 @@ public class ConfigNotesMigration extends NotesMigration {
|
||||
checkArgument(lk.equals(WRITE) || lk.equals(READ),
|
||||
"invalid NoteDb key: %s.%s", t, key);
|
||||
}
|
||||
boolean write = cfg.getBoolean(NOTE_DB, t, WRITE, false);
|
||||
boolean read = cfg.getBoolean(NOTE_DB, t, READ, false);
|
||||
checkArgument(!(read && !write),
|
||||
"must have write enabled when read enabled: %s", t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +91,7 @@ public class ConfigNotesMigration extends NotesMigration {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean writeChanges() {
|
||||
protected boolean writeChanges() {
|
||||
return writeChanges;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ import java.util.Set;
|
||||
* of updates, use {@link #stage()}.
|
||||
*/
|
||||
public class NoteDbUpdateManager {
|
||||
public static String CHANGES_READ_ONLY = "NoteDb changes are read-only";
|
||||
|
||||
public interface Factory {
|
||||
NoteDbUpdateManager create(Project.NameKey projectName);
|
||||
}
|
||||
@@ -249,7 +251,7 @@ public class NoteDbUpdateManager {
|
||||
}
|
||||
|
||||
private boolean isEmpty() {
|
||||
if (!migration.writeChanges()) {
|
||||
if (!migration.commitChangeWrites()) {
|
||||
return true;
|
||||
}
|
||||
return changeUpdates.isEmpty()
|
||||
@@ -382,6 +384,10 @@ public class NoteDbUpdateManager {
|
||||
}
|
||||
|
||||
public void execute() throws OrmException, IOException {
|
||||
// Check before even inspecting the list, as this is a programmer error.
|
||||
if (migration.failChangeWrites()) {
|
||||
throw new OrmException(CHANGES_READ_ONLY);
|
||||
}
|
||||
if (isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,9 +34,33 @@ package com.google.gerrit.server.notedb;
|
||||
* these reasons, the options remain undocumented.
|
||||
*/
|
||||
public abstract class NotesMigration {
|
||||
/**
|
||||
* Read changes from NoteDb.
|
||||
* <p>
|
||||
* Change data is read from NoteDb refs, but ReviewDb is still the source of
|
||||
* truth. If the loader determines NoteDb is out of date, the change data in
|
||||
* NoteDb will be transparently rebuilt. This means that some code paths that
|
||||
* look read-only may in fact attempt to write.
|
||||
* <p>
|
||||
* If true and {@code writeChanges() = false}, changes can still be read from
|
||||
* NoteDb, but any attempts to write will generate an error.
|
||||
*/
|
||||
public abstract boolean readChanges();
|
||||
|
||||
public abstract boolean writeChanges();
|
||||
/**
|
||||
* Write changes to NoteDb.
|
||||
* <p>
|
||||
* Updates to change data are written to NoteDb refs, but ReviewDb is still
|
||||
* the source of truth. Change data will not be written unless the NoteDb refs
|
||||
* are already up to date, and the write path will attempt to rebuild the
|
||||
* change if not.
|
||||
* <p>
|
||||
* If false, the behavior when attempting to write depends on
|
||||
* {@code readChanges()}. If {@code readChanges() = false}, writes to NoteDb
|
||||
* are simply ignored; if {@code true}, any attempts to write will generate an
|
||||
* error.
|
||||
*/
|
||||
protected abstract boolean writeChanges();
|
||||
|
||||
public abstract boolean readAccounts();
|
||||
|
||||
@@ -51,6 +75,24 @@ public abstract class NotesMigration {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean commitChangeWrites() {
|
||||
// It may seem odd that readChanges() without writeChanges() means we should
|
||||
// attempt to commit writes. However, this method is used by callers to know
|
||||
// whether or not they should short-circuit and skip attempting to read or
|
||||
// write NoteDb refs.
|
||||
//
|
||||
// It is possible for commitChangeWrites() to return true and
|
||||
// failChangeWrites() to also return true, causing an error later in the
|
||||
// same codepath. This specific condition is used by the auto-rebuilding
|
||||
// path to rebuild a change and stage the results, but not commit them due
|
||||
// to failChangeWrites().
|
||||
return writeChanges() || readChanges();
|
||||
}
|
||||
|
||||
public boolean failChangeWrites() {
|
||||
return !writeChanges() && readChanges();
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return writeChanges() || readChanges()
|
||||
|| writeAccounts() || readAccounts();
|
||||
|
||||
@@ -29,6 +29,8 @@ public class TestNotesMigration extends NotesMigration {
|
||||
return readChanges;
|
||||
}
|
||||
|
||||
// Increase visbility from superclass, as tests may want to check whether
|
||||
// NoteDb data is written in specific migration scenarios.
|
||||
@Override
|
||||
public boolean writeChanges() {
|
||||
return writeChanges;
|
||||
|
||||
Reference in New Issue
Block a user