blob: 86b269bee72ca08cbb0b029b94c146a818a3d815 [file] [log] [blame]
// Copyright (C) 2015 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.git;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.util.concurrent.CheckedFuture;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.PatchLineCommentsUtil;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.index.change.ChangeIndexer;
import com.google.gerrit.server.notedb.ChangeDelete;
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.NoteDbUpdateManager;
import com.google.gerrit.server.notedb.NotesMigration;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.InvalidChangeOperationException;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.NoSuchRefException;
import com.google.gerrit.server.schema.DisabledChangesReviewDbWrapper;
import com.google.inject.assistedinject.Assisted;
import com.google.inject.assistedinject.AssistedInject;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TimeZone;
import java.util.TreeMap;
/**
* Context for a set of updates that should be applied for a site.
* <p>
* An update operation can be divided into three phases:
* <ol>
* <li>Git reference updates</li>
* <li>Database updates</li>
* <li>Post-update steps<li>
* </ol>
* A single conceptual operation, such as a REST API call or a merge operation,
* may make multiple changes at each step, which all need to be serialized
* relative to each other. Moreover, for consistency, <em>all</em> git ref
* updates must be performed before <em>any</em> database updates, since
* database updates might refer to newly-created patch set refs. And all
* post-update steps, such as hooks, should run only after all storage
* mutations have completed.
* <p>
* Depending on the backend used, each step might support batching, for example
* in a {@code BatchRefUpdate} or one or more database transactions. All
* operations in one phase must complete successfully before proceeding to the
* next phase.
*/
public class BatchUpdate implements AutoCloseable {
public interface Factory {
BatchUpdate create(ReviewDb db, Project.NameKey project,
CurrentUser user, Timestamp when);
}
/** Order of execution of the various phases. */
public enum Order {
/**
* Update the repository and execute all ref updates before touching the
* database.
* <p>
* The default and most common, as Gerrit does not behave well when a patch
* set has no corresponding ref in the repo.
*/
REPO_BEFORE_DB,
/**
* Update the database before touching the repository.
* <p>
* Generally only used when deleting patch sets, which should be deleted
* first from the database (for the same reason as above.)
*/
DB_BEFORE_REPO;
}
public class Context {
private Repository repoWrapper;
public Repository getRepository() throws IOException {
if (repoWrapper == null) {
repoWrapper = new ReadOnlyRepository(BatchUpdate.this.getRepository());
}
return repoWrapper;
}
public RevWalk getRevWalk() throws IOException {
return BatchUpdate.this.getRevWalk();
}
public Project.NameKey getProject() {
return project;
}
public Timestamp getWhen() {
return when;
}
public ReviewDb getDb() {
return db;
}
public CurrentUser getUser() {
return user;
}
public Order getOrder() {
return order;
}
}
public class RepoContext extends Context {
@Override
public Repository getRepository() throws IOException {
return BatchUpdate.this.getRepository();
}
public ObjectInserter getInserter() throws IOException {
return BatchUpdate.this.getObjectInserter();
}
public void addRefUpdate(ReceiveCommand cmd) {
commands.add(cmd);
}
public TimeZone getTimeZone() {
return tz;
}
}
public class ChangeContext extends Context {
private final ChangeControl ctl;
private final Map<PatchSet.Id, ChangeUpdate> updates;
private final ReviewDbWrapper dbWrapper;
private boolean deleted;
private boolean bumpLastUpdatedOn = true;
private ChangeContext(ChangeControl ctl, ReviewDbWrapper dbWrapper) {
this.ctl = ctl;
this.dbWrapper = dbWrapper;
updates = new TreeMap<>(ReviewDbUtil.intKeyOrdering());
}
@Override
public ReviewDb getDb() {
checkNotNull(dbWrapper);
return dbWrapper;
}
public ChangeUpdate getUpdate(PatchSet.Id psId) {
ChangeUpdate u = updates.get(psId);
if (u == null) {
u = changeUpdateFactory.create(ctl, when);
if (newChanges.containsKey(ctl.getId())) {
u.setAllowWriteToNewRef(true);
}
u.setPatchSetId(psId);
updates.put(psId, u);
}
return u;
}
public ChangeNotes getNotes() {
ChangeNotes n = ctl.getNotes();
checkNotNull(n);
return n;
}
public ChangeControl getControl() {
checkNotNull(ctl);
return ctl;
}
public Change getChange() {
Change c = ctl.getChange();
checkNotNull(c);
return c;
}
public void bumpLastUpdatedOn(boolean bump) {
bumpLastUpdatedOn = bump;
}
public void deleteChange() {
deleted = true;
}
}
public static class Op {
@SuppressWarnings("unused")
public void updateRepo(RepoContext ctx) throws Exception {
}
/**
* Override this method to modify a change.
*
* @return whether anything was changed that might require a write to
* the metadata storage.
*/
@SuppressWarnings("unused")
public boolean updateChange(ChangeContext ctx) throws Exception {
return false;
}
// TODO(dborowitz): Support async operations?
@SuppressWarnings("unused")
public void postUpdate(Context ctx) throws Exception {
}
}
public abstract static class InsertChangeOp extends Op {
public abstract Change createChange(Context ctx);
}
/**
* Interface for listening during batch update execution.
* <p>
* When used during execution of multiple batch updates, the {@code after*}
* methods are called after that phase has been completed for <em>all</em> updates.
*/
public static class Listener {
private static final Listener NONE = new Listener();
/**
* Called after updating all repositories and flushing objects but before
* updating any refs.
*/
public void afterUpdateRepos() throws Exception {
}
/** Called after updating all refs. */
public void afterRefUpdates() throws Exception {
}
/** Called after updating all changes. */
public void afterUpdateChanges() throws Exception {
}
}
private static Order getOrder(Collection<BatchUpdate> updates) {
Order o = null;
for (BatchUpdate u : updates) {
if (o == null) {
o = u.order;
} else if (u.order != o) {
throw new IllegalArgumentException("cannot mix execution orders");
}
}
return o;
}
static void execute(Collection<BatchUpdate> updates, Listener listener)
throws UpdateException, RestApiException {
if (updates.isEmpty()) {
return;
}
try {
Order order = getOrder(updates);
switch (order) {
case REPO_BEFORE_DB:
for (BatchUpdate u : updates) {
u.executeUpdateRepo();
}
listener.afterUpdateRepos();
for (BatchUpdate u : updates) {
u.executeRefUpdates();
}
listener.afterRefUpdates();
for (BatchUpdate u : updates) {
u.executeChangeOps();
}
listener.afterUpdateChanges();
break;
case DB_BEFORE_REPO:
for (BatchUpdate u : updates) {
u.executeChangeOps();
}
listener.afterUpdateChanges();
for (BatchUpdate u : updates) {
u.executeUpdateRepo();
}
listener.afterUpdateRepos();
for (BatchUpdate u : updates) {
u.executeRefUpdates();
}
listener.afterRefUpdates();
break;
default:
throw new IllegalStateException("invalid execution order: " + order);
}
List<CheckedFuture<?, IOException>> indexFutures = new ArrayList<>();
for (BatchUpdate u : updates) {
indexFutures.addAll(u.indexFutures);
}
ChangeIndexer.allAsList(indexFutures).get();
for (BatchUpdate u : updates) {
if (u.batchRefUpdate != null) {
// Fire ref update events only after all mutations are finished, since
// callers may assume a patch set ref being created means the change
// was created, or a branch advancing meaning some changes were
// closed.
u.gitRefUpdated.fire(u.project, u.batchRefUpdate);
}
}
for (BatchUpdate u : updates) {
u.executePostOps();
}
} catch (UpdateException | RestApiException e) {
// Propagate REST API exceptions thrown by operations; they commonly throw
// exceptions like ResourceConflictException to indicate an atomic update
// failure.
throw e;
// Convert other common non-REST exception types with user-visible
// messages to corresponding REST exception types
} catch (InvalidChangeOperationException e) {
throw new ResourceConflictException(e.getMessage(), e);
} catch (NoSuchChangeException | NoSuchRefException
| NoSuchProjectException e) {
throw new ResourceNotFoundException(e.getMessage(), e);
} catch (Exception e) {
Throwables.propagateIfPossible(e);
throw new UpdateException(e);
}
}
private final ReviewDb db;
private final GitRepositoryManager repoManager;
private final ChangeIndexer indexer;
private final ChangeControl.GenericFactory changeControlFactory;
private final ChangeNotes.Factory changeNotesFactory;
private final ChangeUpdate.Factory changeUpdateFactory;
private final NoteDbUpdateManager.Factory updateManagerFactory;
private final GitReferenceUpdated gitRefUpdated;
private final NotesMigration notesMigration;
private final PatchLineCommentsUtil plcUtil;
private final Project.NameKey project;
private final CurrentUser user;
private final Timestamp when;
private final TimeZone tz;
private final ListMultimap<Change.Id, Op> ops =
MultimapBuilder.linkedHashKeys().arrayListValues().build();
private final Map<Change.Id, Change> newChanges = new HashMap<>();
private final List<CheckedFuture<?, IOException>> indexFutures =
new ArrayList<>();
private Repository repo;
private ObjectInserter inserter;
private RevWalk revWalk;
private ChainedReceiveCommands commands = new ChainedReceiveCommands();
private BatchRefUpdate batchRefUpdate;
private boolean closeRepo;
private Order order;
@AssistedInject
BatchUpdate(GitRepositoryManager repoManager,
ChangeIndexer indexer,
ChangeControl.GenericFactory changeControlFactory,
ChangeNotes.Factory changeNotesFactory,
ChangeUpdate.Factory changeUpdateFactory,
NoteDbUpdateManager.Factory updateManagerFactory,
GitReferenceUpdated gitRefUpdated,
NotesMigration notesMigration,
PatchLineCommentsUtil plcUtil,
@GerritPersonIdent PersonIdent serverIdent,
@Assisted ReviewDb db,
@Assisted Project.NameKey project,
@Assisted CurrentUser user,
@Assisted Timestamp when) {
this.db = db;
this.repoManager = repoManager;
this.indexer = indexer;
this.changeControlFactory = changeControlFactory;
this.changeNotesFactory = changeNotesFactory;
this.changeUpdateFactory = changeUpdateFactory;
this.updateManagerFactory = updateManagerFactory;
this.gitRefUpdated = gitRefUpdated;
this.notesMigration = notesMigration;
this.plcUtil = plcUtil;
this.project = project;
this.user = user;
this.when = when;
tz = serverIdent.getTimeZone();
order = Order.REPO_BEFORE_DB;
}
@Override
public void close() {
if (closeRepo) {
revWalk.close();
inserter.close();
repo.close();
}
}
public BatchUpdate setRepository(Repository repo, RevWalk revWalk,
ObjectInserter inserter) {
checkState(this.repo == null, "repo already set");
closeRepo = false;
this.repo = checkNotNull(repo, "repo");
this.revWalk = checkNotNull(revWalk, "revWalk");
this.inserter = checkNotNull(inserter, "inserter");
return this;
}
public BatchUpdate setOrder(Order order) {
this.order = order;
return this;
}
private void initRepository() throws IOException {
if (repo == null) {
this.repo = repoManager.openRepository(project);
closeRepo = true;
inserter = repo.newObjectInserter();
revWalk = new RevWalk(inserter.newReader());
}
}
public CurrentUser getUser() {
return user;
}
public Repository getRepository() throws IOException {
initRepository();
return repo;
}
public RevWalk getRevWalk() throws IOException {
initRepository();
return revWalk;
}
public ObjectInserter getObjectInserter() throws IOException {
initRepository();
return inserter;
}
public BatchUpdate addOp(Change.Id id, Op op) {
checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
ops.put(id, op);
return this;
}
public BatchUpdate insertChange(InsertChangeOp op) {
Context ctx = new Context();
Change c = op.createChange(ctx);
checkArgument(!newChanges.containsKey(c.getId()),
"only one op allowed to create change %s", c.getId());
newChanges.put(c.getId(), c);
ops.get(c.getId()).add(0, op);
return this;
}
public void execute() throws UpdateException, RestApiException {
execute(Listener.NONE);
}
public void execute(Listener listener)
throws UpdateException, RestApiException {
execute(ImmutableList.of(this), listener);
}
private void executeUpdateRepo() throws UpdateException, RestApiException {
try {
RepoContext ctx = new RepoContext();
for (Op op : ops.values()) {
op.updateRepo(ctx);
}
if (inserter != null) {
inserter.flush();
}
} catch (Exception e) {
Throwables.propagateIfPossible(e, RestApiException.class);
throw new UpdateException(e);
}
}
private void executeRefUpdates() throws IOException, UpdateException {
if (commands.isEmpty()) {
return;
}
// May not be opened if the caller added ref updates but no new objects.
initRepository();
batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
commands.addTo(batchRefUpdate);
batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
boolean ok = true;
for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
if (cmd.getResult() != ReceiveCommand.Result.OK) {
ok = false;
break;
}
}
if (!ok) {
throw new UpdateException("BatchRefUpdate failed: " + batchRefUpdate);
}
}
private void executeChangeOps() throws UpdateException, RestApiException {
try {
for (Map.Entry<Change.Id, Collection<Op>> e : ops.asMap().entrySet()) {
Change.Id id = e.getKey();
db.changes().beginTransaction(id);
ChangeContext ctx;
NoteDbUpdateManager updateManager = null;
boolean dirty = false;
try {
ctx = newChangeContext(id);
// Call updateChange on each op.
for (Op op : e.getValue()) {
dirty |= op.updateChange(ctx);
}
if (!dirty) {
return;
}
// Stage the NoteDb update and store its state in the Change.
if (!ctx.deleted && notesMigration.writeChanges()) {
updateManager = updateManagerFactory.create(ctx.getProject());
for (ChangeUpdate u : ctx.updates.values()) {
updateManager.add(u);
}
NoteDbChangeState.applyDelta(
ctx.getChange(),
updateManager.stage().get(id));
}
// Bump lastUpdatedOn or rowVersion and commit.
Iterable<Change> cs = changesToUpdate(ctx);
if (newChanges.containsKey(id)) {
// Insert rather than upsert in case of a race on change IDs.
db.changes().insert(cs);
} else if (ctx.deleted) {
db.changes().delete(cs);
} else {
db.changes().update(cs);
}
db.commit();
} finally {
db.rollback();
}
// Execute NoteDb updates after committing ReviewDb updates.
if (notesMigration.writeChanges()) {
if (updateManager != null) {
updateManager.execute();
}
if (ctx.deleted) {
new ChangeDelete(plcUtil, getRepository(), ctx.getNotes()).delete();
}
}
// Reindex changes.
if (ctx.deleted) {
indexFutures.add(indexer.deleteAsync(id));
} else {
indexFutures.add(indexer.indexAsync(ctx.getProject(), id));
}
}
} catch (Exception e) {
Throwables.propagateIfPossible(e, RestApiException.class);
throw new UpdateException(e);
}
}
private static Iterable<Change> changesToUpdate(ChangeContext ctx) {
Change c = ctx.getChange();
if (ctx.bumpLastUpdatedOn && c.getLastUpdatedOn().before(ctx.getWhen())) {
c.setLastUpdatedOn(ctx.getWhen());
}
return Collections.singleton(c);
}
private ChangeContext newChangeContext(Change.Id id) throws Exception {
Change c = newChanges.get(id);
if (c == null) {
c = unwrap(db).changes().get(id);
}
// Pass in preloaded change to controlFor, to avoid:
// - reading from a db that does not belong to this update
// - attempting to read a change that doesn't exist yet
ChangeNotes notes = changeNotesFactory.createForNew(c);
ChangeContext ctx = new ChangeContext(
changeControlFactory.controlFor(notes, user), new BatchUpdateReviewDb(db));
return ctx;
}
private void executePostOps() throws Exception {
Context ctx = new Context();
for (Op op : ops.values()) {
op.postUpdate(ctx);
}
}
private static ReviewDb unwrap(ReviewDb db) {
if (db instanceof DisabledChangesReviewDbWrapper) {
db = ((DisabledChangesReviewDbWrapper) db).unsafeGetDelegate();
}
return db;
}
}