blob: dd3cc7380ab9d3bfbea7a8e0865d495001e859ba [file] [log] [blame]
// 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.checkNotNull;
import static com.google.common.base.Preconditions.checkState;
import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ListMultimap;
import com.google.common.collect.MultimapBuilder;
import com.google.common.collect.Multiset;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.config.FactoryModule;
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.Project;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.validators.OnSubmitValidators;
import com.google.gerrit.server.notedb.NotesMigration;
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.util.RequestId;
import com.google.inject.Inject;
import com.google.inject.Module;
import com.google.inject.Singleton;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TimeZone;
import org.eclipse.jgit.lib.BatchRefUpdate;
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.PushCertificate;
import org.eclipse.jgit.transport.ReceiveCommand;
/**
* Helper 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>Database updates
* <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 abstract class BatchUpdate implements AutoCloseable {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
public static Module module() {
return new FactoryModule() {
@Override
public void configure() {
factory(ReviewDbBatchUpdate.AssistedFactory.class);
factory(NoteDbBatchUpdate.AssistedFactory.class);
}
};
}
@Singleton
public static class Factory {
private final NotesMigration migration;
private final ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory;
private final NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory;
// TODO(dborowitz): Make this non-injectable to force all callers to use RetryHelper.
@Inject
Factory(
NotesMigration migration,
ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
this.migration = migration;
this.reviewDbBatchUpdateFactory = reviewDbBatchUpdateFactory;
this.noteDbBatchUpdateFactory = noteDbBatchUpdateFactory;
}
public BatchUpdate create(
ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
if (migration.disableChangeReviewDb()) {
return noteDbBatchUpdateFactory.create(db, project, user, when);
}
return reviewDbBatchUpdateFactory.create(db, project, user, when);
}
@SuppressWarnings({"rawtypes", "unchecked"})
public void execute(
Collection<BatchUpdate> updates,
BatchUpdateListener listener,
@Nullable RequestId requestId,
boolean dryRun)
throws UpdateException, RestApiException {
checkNotNull(listener);
checkDifferentProject(updates);
// It's safe to downcast all members of the input collection in this case, because the only
// way a caller could have gotten any BatchUpdates in the first place is to call the create
// method above, which always returns instances of the type we expect. Just to be safe,
// copy them into an ImmutableList so there is no chance the callee can pollute the input
// collection.
if (migration.disableChangeReviewDb()) {
ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
(ImmutableList) ImmutableList.copyOf(updates);
NoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
} else {
ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
(ImmutableList) ImmutableList.copyOf(updates);
ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
}
}
private static void checkDifferentProject(Collection<BatchUpdate> updates) {
Multiset<Project.NameKey> projectCounts =
updates.stream().map(u -> u.project).collect(toImmutableMultiset());
checkArgument(
projectCounts.entrySet().size() == updates.size(),
"updates must all be for different projects, got: %s",
projectCounts);
}
}
static void setRequestIds(
Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
if (requestId != null) {
for (BatchUpdate u : updates) {
checkArgument(
u.requestId == null || u.requestId == requestId,
"refusing to overwrite RequestId %s in update with %s",
u.requestId,
requestId);
u.setRequestId(requestId);
}
}
}
static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
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");
}
}
if (o != Order.REPO_BEFORE_DB) {
checkArgument(
listener == BatchUpdateListener.NONE,
"BatchUpdateListener not supported for order %s",
o);
}
return o;
}
static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
checkArgument(!updates.isEmpty());
Boolean p = null;
for (BatchUpdate u : updates) {
if (p == null) {
p = u.updateChangesInParallel;
} else if (u.updateChangesInParallel != p) {
throw new IllegalArgumentException("cannot mix parallel and non-parallel operations");
}
}
// Properly implementing this would involve hoisting the parallel loop up
// even further. As of this writing, the only user is ReceiveCommits,
// which only executes a single BatchUpdate at a time. So bail for now.
checkArgument(
!p || updates.size() <= 1,
"cannot execute ChangeOps in parallel with more than 1 BatchUpdate");
return p;
}
static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
Throwables.throwIfUnchecked(e);
// Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
// ResourceConflictException to indicate an atomic update failure.
Throwables.throwIfInstanceOf(e, UpdateException.class);
Throwables.throwIfInstanceOf(e, RestApiException.class);
// Convert other common non-REST exception types with user-visible messages to corresponding
// REST exception types
if (e instanceof InvalidChangeOperationException) {
throw new ResourceConflictException(e.getMessage(), e);
} else if (e instanceof NoSuchChangeException
|| e instanceof NoSuchRefException
|| e instanceof NoSuchProjectException) {
throw new ResourceNotFoundException(e.getMessage(), e);
}
// Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
throw new UpdateException(e);
}
protected GitRepositoryManager repoManager;
protected final Project.NameKey project;
protected final CurrentUser user;
protected final Timestamp when;
protected final TimeZone tz;
protected final ListMultimap<Change.Id, BatchUpdateOp> ops =
MultimapBuilder.linkedHashKeys().arrayListValues().build();
protected final Map<Change.Id, Change> newChanges = new HashMap<>();
protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
protected RepoView repoView;
protected BatchRefUpdate batchRefUpdate;
protected Order order;
protected OnSubmitValidators onSubmitValidators;
protected RequestId requestId;
protected PushCertificate pushCert;
protected String refLogMessage;
private boolean updateChangesInParallel;
protected BatchUpdate(
GitRepositoryManager repoManager,
PersonIdent serverIdent,
Project.NameKey project,
CurrentUser user,
Timestamp when) {
this.repoManager = repoManager;
this.project = project;
this.user = user;
this.when = when;
tz = serverIdent.getTimeZone();
order = Order.REPO_BEFORE_DB;
}
@Override
public void close() {
if (repoView != null) {
repoView.close();
}
}
public abstract void execute(BatchUpdateListener listener)
throws UpdateException, RestApiException;
public void execute() throws UpdateException, RestApiException {
execute(BatchUpdateListener.NONE);
}
protected abstract Context newContext();
public BatchUpdate setRequestId(RequestId requestId) {
this.requestId = requestId;
return this;
}
public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
checkState(this.repoView == null, "repo already set");
repoView = new RepoView(repo, revWalk, inserter);
return this;
}
public BatchUpdate setPushCertificate(@Nullable PushCertificate pushCert) {
this.pushCert = pushCert;
return this;
}
public BatchUpdate setRefLogMessage(@Nullable String refLogMessage) {
this.refLogMessage = refLogMessage;
return this;
}
public BatchUpdate setOrder(Order order) {
this.order = order;
return this;
}
/**
* Add a validation step for intended ref operations, which will be performed at the end of {@link
* RepoOnlyOp#updateRepo(RepoContext)} step.
*/
public BatchUpdate setOnSubmitValidators(OnSubmitValidators onSubmitValidators) {
this.onSubmitValidators = onSubmitValidators;
return this;
}
/**
* Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change.
*
* <p>This improves performance of writing to multiple changes in separate ReviewDb transactions.
* When only NoteDb is used, updates to all changes are written in a single batch ref update, so
* parallelization is not used and this option is ignored.
*/
public BatchUpdate updateChangesInParallel() {
this.updateChangesInParallel = true;
return this;
}
protected void initRepository() throws IOException {
if (repoView == null) {
repoView = new RepoView(repoManager, project);
}
}
protected RepoView getRepoView() throws IOException {
initRepository();
return repoView;
}
protected CurrentUser getUser() {
return user;
}
protected Optional<AccountState> getAccount() {
return user.isIdentifiedUser()
? Optional.of(user.asIdentifiedUser().state())
: Optional.empty();
}
protected RevWalk getRevWalk() throws IOException {
initRepository();
return repoView.getRevWalk();
}
public Map<String, ReceiveCommand> getRefUpdates() {
return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
}
public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
checkArgument(!(op instanceof InsertChangeOp), "use insertChange");
checkNotNull(op);
ops.put(id, op);
return this;
}
public BatchUpdate addRepoOnlyOp(RepoOnlyOp op) {
checkArgument(!(op instanceof BatchUpdateOp), "use addOp()");
repoOnlyOps.add(op);
return this;
}
public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
Context ctx = newContext();
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;
}
protected void logDebug(String msg, Throwable t) {
// Only log if there is a requestId assigned, since those are the
// expensive/complicated requests like MergeOp. Doing it every time would be
// noisy.
if (requestId != null) {
logger.atFine().withCause(t).log(requestId + "%s", msg);
}
}
protected void logDebug(String msg) {
// Only log if there is a requestId assigned, since those are the
// expensive/complicated requests like MergeOp. Doing it every time would be
// noisy.
if (requestId != null) {
logger.atFine().log(requestId + msg);
}
}
protected void logDebug(String msg, @Nullable Object arg) {
// Only log if there is a requestId assigned, since those are the
// expensive/complicated requests like MergeOp. Doing it every time would be
// noisy.
if (requestId != null) {
logger.atFine().log(requestId + msg, arg);
}
}
protected void logDebug(String msg, @Nullable Object arg1, @Nullable Object arg2) {
// Only log if there is a requestId assigned, since those are the
// expensive/complicated requests like MergeOp. Doing it every time would be
// noisy.
if (requestId != null) {
logger.atFine().log(requestId + msg, arg1, arg2);
}
}
}