| // 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 java.util.Comparator.comparing; |
| import static java.util.stream.Collectors.toList; |
| |
| import com.google.common.base.Throwables; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.Maps; |
| import com.google.common.util.concurrent.CheckedFuture; |
| import com.google.gerrit.common.Nullable; |
| 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.server.CurrentUser; |
| import com.google.gerrit.server.GerritPersonIdent; |
| import com.google.gerrit.server.extensions.events.GitReferenceUpdated; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.index.change.ChangeIndexer; |
| import com.google.gerrit.server.notedb.ChangeNotes; |
| import com.google.gerrit.server.notedb.ChangeUpdate; |
| import com.google.gerrit.server.notedb.NoteDbUpdateManager; |
| import com.google.gerrit.server.project.ChangeControl; |
| import com.google.gerrit.server.util.RequestId; |
| import com.google.gwtorm.server.OrmException; |
| import com.google.inject.Inject; |
| import com.google.inject.assistedinject.Assisted; |
| import java.io.IOException; |
| import java.sql.Timestamp; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.TimeZone; |
| import java.util.TreeMap; |
| import org.eclipse.jgit.lib.NullProgressMonitor; |
| import org.eclipse.jgit.lib.ObjectInserter; |
| import org.eclipse.jgit.lib.ObjectReader; |
| import org.eclipse.jgit.lib.PersonIdent; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.eclipse.jgit.transport.ReceiveCommand; |
| |
| /** |
| * {@link BatchUpdate} implementation that only supports NoteDb. |
| * |
| * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not |
| * consulted during updates. |
| */ |
| class UnfusedNoteDbBatchUpdate extends BatchUpdate { |
| interface AssistedFactory { |
| UnfusedNoteDbBatchUpdate create( |
| ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when); |
| } |
| |
| static void execute( |
| ImmutableList<UnfusedNoteDbBatchUpdate> updates, |
| BatchUpdateListener listener, |
| @Nullable RequestId requestId, |
| boolean dryrun) |
| throws UpdateException, RestApiException { |
| if (updates.isEmpty()) { |
| return; |
| } |
| setRequestIds(updates, requestId); |
| |
| try { |
| Order order = getOrder(updates, listener); |
| // TODO(dborowitz): Fuse implementations to use a single BatchRefUpdate between phases. Note |
| // that we may still need to respect the order, since op implementations may make assumptions |
| // about the order in which their methods are called. |
| switch (order) { |
| case REPO_BEFORE_DB: |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.executeUpdateRepo(); |
| } |
| listener.afterUpdateRepos(); |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.executeRefUpdates(dryrun); |
| } |
| listener.afterUpdateRefs(); |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.reindexChanges(u.executeChangeOps(dryrun), dryrun); |
| } |
| listener.afterUpdateChanges(); |
| break; |
| case DB_BEFORE_REPO: |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.reindexChanges(u.executeChangeOps(dryrun), dryrun); |
| } |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.executeUpdateRepo(); |
| } |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.executeRefUpdates(dryrun); |
| } |
| break; |
| default: |
| throw new IllegalStateException("invalid execution order: " + order); |
| } |
| |
| ChangeIndexer.allAsList( |
| updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList())) |
| .get(); |
| |
| // 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. |
| updates |
| .stream() |
| .filter(u -> u.batchRefUpdate != null) |
| .forEach( |
| u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null))); |
| |
| if (!dryrun) { |
| for (UnfusedNoteDbBatchUpdate u : updates) { |
| u.executePostOps(); |
| } |
| } |
| } catch (Exception e) { |
| wrapAndThrowException(e); |
| } |
| } |
| |
| class ContextImpl implements Context { |
| @Override |
| public RepoView getRepoView() throws IOException { |
| return UnfusedNoteDbBatchUpdate.this.getRepoView(); |
| } |
| |
| @Override |
| public RevWalk getRevWalk() throws IOException { |
| return getRepoView().getRevWalk(); |
| } |
| |
| @Override |
| public Project.NameKey getProject() { |
| return project; |
| } |
| |
| @Override |
| public Timestamp getWhen() { |
| return when; |
| } |
| |
| @Override |
| public TimeZone getTimeZone() { |
| return tz; |
| } |
| |
| @Override |
| public ReviewDb getDb() { |
| return db; |
| } |
| |
| @Override |
| public CurrentUser getUser() { |
| return user; |
| } |
| |
| @Override |
| public Order getOrder() { |
| return order; |
| } |
| } |
| |
| private class RepoContextImpl extends ContextImpl implements RepoContext { |
| @Override |
| public ObjectInserter getInserter() throws IOException { |
| return getRepoView().getInserterWrapper(); |
| } |
| |
| @Override |
| public void addRefUpdate(ReceiveCommand cmd) throws IOException { |
| getRepoView().getCommands().add(cmd); |
| } |
| } |
| |
| private class ChangeContextImpl extends ContextImpl implements ChangeContext { |
| private final ChangeControl ctl; |
| private final Map<PatchSet.Id, ChangeUpdate> updates; |
| |
| private boolean deleted; |
| |
| protected ChangeContextImpl(ChangeControl ctl) { |
| this.ctl = checkNotNull(ctl); |
| updates = new TreeMap<>(comparing(PatchSet.Id::get)); |
| } |
| |
| @Override |
| 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; |
| } |
| |
| @Override |
| public ChangeControl getControl() { |
| return ctl; |
| } |
| |
| @Override |
| public void dontBumpLastUpdatedOn() { |
| // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the |
| // change meta ref. |
| } |
| |
| @Override |
| public void deleteChange() { |
| deleted = true; |
| } |
| } |
| |
| /** Per-change result status from {@link #executeChangeOps}. */ |
| private enum ChangeResult { |
| SKIPPED, |
| UPSERTED, |
| DELETED; |
| } |
| |
| private final ChangeNotes.Factory changeNotesFactory; |
| private final ChangeControl.GenericFactory changeControlFactory; |
| private final ChangeUpdate.Factory changeUpdateFactory; |
| private final NoteDbUpdateManager.Factory updateManagerFactory; |
| private final ChangeIndexer indexer; |
| private final GitReferenceUpdated gitRefUpdated; |
| private final ReviewDb db; |
| |
| private List<CheckedFuture<?, IOException>> indexFutures; |
| |
| @Inject |
| UnfusedNoteDbBatchUpdate( |
| GitRepositoryManager repoManager, |
| @GerritPersonIdent PersonIdent serverIdent, |
| ChangeNotes.Factory changeNotesFactory, |
| ChangeControl.GenericFactory changeControlFactory, |
| ChangeUpdate.Factory changeUpdateFactory, |
| NoteDbUpdateManager.Factory updateManagerFactory, |
| ChangeIndexer indexer, |
| GitReferenceUpdated gitRefUpdated, |
| @Assisted ReviewDb db, |
| @Assisted Project.NameKey project, |
| @Assisted CurrentUser user, |
| @Assisted Timestamp when) { |
| super(repoManager, serverIdent, project, user, when); |
| checkArgument(!db.changesTablesEnabled(), "expected Change tables to be disabled on %s", db); |
| this.changeNotesFactory = changeNotesFactory; |
| this.changeControlFactory = changeControlFactory; |
| this.changeUpdateFactory = changeUpdateFactory; |
| this.updateManagerFactory = updateManagerFactory; |
| this.indexer = indexer; |
| this.gitRefUpdated = gitRefUpdated; |
| this.db = db; |
| this.indexFutures = new ArrayList<>(); |
| } |
| |
| @Override |
| public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException { |
| execute(ImmutableList.of(this), listener, requestId, false); |
| } |
| |
| @Override |
| protected Context newContext() { |
| return new ContextImpl(); |
| } |
| |
| private void executeUpdateRepo() throws UpdateException, RestApiException { |
| try { |
| logDebug("Executing updateRepo on {} ops", ops.size()); |
| RepoContextImpl ctx = new RepoContextImpl(); |
| for (BatchUpdateOp op : ops.values()) { |
| op.updateRepo(ctx); |
| } |
| |
| logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size()); |
| for (RepoOnlyOp op : repoOnlyOps) { |
| op.updateRepo(ctx); |
| } |
| |
| if (onSubmitValidators != null && !getRefUpdates().isEmpty()) { |
| // Validation of refs has to take place here and not at the beginning of executeRefUpdates. |
| // Otherwise, failing validation in a second BatchUpdate object will happen *after* the |
| // first update's executeRefUpdates has finished, hence after first repo's refs have been |
| // updated, which is too late. |
| onSubmitValidators.validate( |
| project, ctx.getRevWalk().getObjectReader(), repoView.getCommands()); |
| } |
| |
| // TODO(dborowitz): Don't flush when fusing phases. |
| if (repoView != null) { |
| logDebug("Flushing inserter"); |
| repoView.getInserter().flush(); |
| } else { |
| logDebug("No objects to flush"); |
| } |
| } catch (Exception e) { |
| Throwables.throwIfInstanceOf(e, RestApiException.class); |
| throw new UpdateException(e); |
| } |
| } |
| |
| // TODO(dborowitz): Don't execute non-change ref updates separately when fusing phases. |
| private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException { |
| if (getRefUpdates().isEmpty()) { |
| logDebug("No ref updates to execute"); |
| return; |
| } |
| // May not be opened if the caller added ref updates but no new objects. |
| initRepository(); |
| batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate(); |
| repoView.getCommands().addTo(batchRefUpdate); |
| logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size()); |
| if (dryrun) { |
| return; |
| } |
| |
| // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one |
| // that might have access to unflushed objects. |
| try (RevWalk updateRw = new RevWalk(repoView.getRepository())) { |
| batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE); |
| } |
| boolean ok = true; |
| for (ReceiveCommand cmd : batchRefUpdate.getCommands()) { |
| if (cmd.getResult() != ReceiveCommand.Result.OK) { |
| ok = false; |
| break; |
| } |
| } |
| if (!ok) { |
| throw new RestApiException("BatchRefUpdate failed: " + batchRefUpdate); |
| } |
| } |
| |
| private Map<Change.Id, ChangeResult> executeChangeOps(boolean dryrun) throws Exception { |
| logDebug("Executing change ops"); |
| Map<Change.Id, ChangeResult> result = |
| Maps.newLinkedHashMapWithExpectedSize(ops.keySet().size()); |
| initRepository(); |
| Repository repo = repoView.getRepository(); |
| // TODO(dborowitz): Teach NoteDbUpdateManager to allow reusing the same inserter and batch ref |
| // update as in executeUpdateRepo. |
| try (ObjectInserter ins = repo.newObjectInserter(); |
| ObjectReader reader = ins.newReader(); |
| RevWalk rw = new RevWalk(reader); |
| NoteDbUpdateManager updateManager = |
| updateManagerFactory |
| .create(project) |
| .setChangeRepo(repo, rw, ins, new ChainedReceiveCommands(repo))) { |
| if (user.isIdentifiedUser()) { |
| updateManager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz)); |
| } |
| for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) { |
| Change.Id id = e.getKey(); |
| ChangeContextImpl ctx = newChangeContext(id); |
| boolean dirty = false; |
| logDebug("Applying {} ops for change {}", e.getValue().size(), id); |
| for (BatchUpdateOp op : e.getValue()) { |
| dirty |= op.updateChange(ctx); |
| } |
| if (!dirty) { |
| logDebug("No ops reported dirty, short-circuiting"); |
| result.put(id, ChangeResult.SKIPPED); |
| continue; |
| } |
| for (ChangeUpdate u : ctx.updates.values()) { |
| updateManager.add(u); |
| } |
| if (ctx.deleted) { |
| logDebug("Change {} was deleted", id); |
| updateManager.deleteChange(id); |
| result.put(id, ChangeResult.DELETED); |
| } else { |
| result.put(id, ChangeResult.UPSERTED); |
| } |
| } |
| |
| if (!dryrun) { |
| logDebug("Executing NoteDb updates"); |
| updateManager.execute(); |
| } |
| } |
| return result; |
| } |
| |
| private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException { |
| logDebug("Opening change {} for update", id); |
| Change c = newChanges.get(id); |
| boolean isNew = c != null; |
| if (!isNew) { |
| // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for |
| // existence and populating columns from the parsed notes state. |
| // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way. |
| c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id); |
| } else { |
| logDebug("Change {} is new", id); |
| } |
| ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew); |
| ChangeControl ctl = changeControlFactory.controlFor(notes, user); |
| return new ChangeContextImpl(ctl); |
| } |
| |
| private void reindexChanges(Map<Change.Id, ChangeResult> updateResults, boolean dryrun) { |
| if (dryrun) { |
| return; |
| } |
| logDebug("Reindexing {} changes", updateResults.size()); |
| for (Map.Entry<Change.Id, ChangeResult> e : updateResults.entrySet()) { |
| Change.Id id = e.getKey(); |
| switch (e.getValue()) { |
| case UPSERTED: |
| indexFutures.add(indexer.indexAsync(project, id)); |
| break; |
| case DELETED: |
| indexFutures.add(indexer.deleteAsync(id)); |
| break; |
| case SKIPPED: |
| break; |
| default: |
| throw new IllegalStateException("unexpected result: " + e.getValue()); |
| } |
| } |
| } |
| |
| private void executePostOps() throws Exception { |
| ContextImpl ctx = new ContextImpl(); |
| for (BatchUpdateOp op : ops.values()) { |
| op.postUpdate(ctx); |
| } |
| |
| for (RepoOnlyOp op : repoOnlyOps) { |
| op.postUpdate(ctx); |
| } |
| } |
| } |