com.google.gerrit.server.notedb.ChangeRebuilderImpl.java Source code

Java tutorial

Introduction

Here is the source code for com.google.gerrit.server.notedb.ChangeRebuilderImpl.java

Source

// Copyright (C) 2014 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.notedb;

import static com.google.common.base.MoreObjects.firstNonNull;
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.gerrit.reviewdb.client.RefNames.changeMetaRef;
import static com.google.gerrit.server.PatchLineCommentsUtil.setCommentRevId;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
import static java.util.concurrent.TimeUnit.SECONDS;

import com.google.common.base.MoreObjects;
import com.google.common.base.Optional;
import com.google.common.base.Predicate;
import com.google.common.base.Splitter;
import com.google.common.collect.ArrayListMultimap;
import com.google.common.collect.ComparisonChain;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Multimap;
import com.google.common.collect.Ordering;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import com.google.common.primitives.Ints;
import com.google.gerrit.common.FormatUtil;
import com.google.gerrit.common.Nullable;
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.PatchLineComment;
import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.reviewdb.server.ReviewDbUtil;
import com.google.gerrit.server.GerritPersonIdent;
import com.google.gerrit.server.PatchLineCommentsUtil;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.config.AnonymousCowardName;
import com.google.gerrit.server.git.ChainedReceiveCommands;
import com.google.gerrit.server.notedb.NoteDbUpdateManager.OpenRepo;
import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
import com.google.gerrit.server.patch.PatchListCache;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gwtorm.server.AtomicUpdate;
import com.google.gwtorm.server.OrmException;
import com.google.gwtorm.server.OrmRuntimeException;
import com.google.gwtorm.server.SchemaFactory;
import com.google.inject.Inject;

import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.InvalidObjectIdException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectInserter;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.TextProgressMonitor;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.ReceiveCommand;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.PrintWriter;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class ChangeRebuilderImpl extends ChangeRebuilder {
    private static final Logger log = LoggerFactory.getLogger(ChangeRebuilderImpl.class);

    /**
     * The maximum amount of time between the ReviewDb timestamp of the first and
     * last events batched together into a single NoteDb update.
     * <p>
     * Used to account for the fact that different records with their own
     * timestamps (e.g. {@link PatchSetApproval} and {@link ChangeMessage})
     * historically didn't necessarily use the same timestamp, and tended to call
     * {@code System.currentTimeMillis()} independently.
     */
    static final long MAX_WINDOW_MS = SECONDS.toMillis(3);

    /**
     * The maximum amount of time between two consecutive events to consider them
     * to be in the same batch.
     */
    private static final long MAX_DELTA_MS = SECONDS.toMillis(1);

    private final AccountCache accountCache;
    private final ChangeDraftUpdate.Factory draftUpdateFactory;
    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;
    private final String anonymousCowardName;

    @Inject
    ChangeRebuilderImpl(SchemaFactory<ReviewDb> schemaFactory, AccountCache accountCache,
            ChangeDraftUpdate.Factory draftUpdateFactory, ChangeNoteUtil changeNoteUtil,
            ChangeUpdate.Factory updateFactory, NoteDbUpdateManager.Factory updateManagerFactory,
            NotesMigration migration, PatchListCache patchListCache, @GerritPersonIdent PersonIdent serverIdent,
            @Nullable ProjectCache projectCache, @AnonymousCowardName String anonymousCowardName) {
        super(schemaFactory);
        this.accountCache = accountCache;
        this.draftUpdateFactory = draftUpdateFactory;
        this.changeNoteUtil = changeNoteUtil;
        this.updateFactory = updateFactory;
        this.updateManagerFactory = updateManagerFactory;
        this.migration = migration;
        this.patchListCache = patchListCache;
        this.serverIdent = serverIdent;
        this.projectCache = projectCache;
        this.anonymousCowardName = anonymousCowardName;
    }

    @Override
    public Result rebuild(ReviewDb db, Change.Id changeId)
            throws NoSuchChangeException, IOException, OrmException, ConfigInvalidException {
        db = ReviewDbUtil.unwrapDb(db);
        Change change = db.changes().get(changeId);
        if (change == null) {
            throw new NoSuchChangeException(changeId);
        }
        try (NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject())) {
            buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
            return execute(db, changeId, manager);
        }
    }

    private static class AbortUpdateException extends OrmRuntimeException {
        private static final long serialVersionUID = 1L;

        AbortUpdateException() {
            super("aborted");
        }
    }

    private static class ConflictingUpdateException extends OrmRuntimeException {
        private static final long serialVersionUID = 1L;

        ConflictingUpdateException(Change change, String expectedNoteDbState) {
            super(String.format("Expected change %s to have noteDbState %s but was %s", change.getId(),
                    expectedNoteDbState, change.getNoteDbState()));
        }
    }

    @Override
    public Result rebuild(NoteDbUpdateManager manager, ChangeBundle bundle)
            throws NoSuchChangeException, IOException, OrmException, ConfigInvalidException {
        Change change = new Change(bundle.getChange());
        buildUpdates(manager, bundle);
        return manager.stageAndApplyDelta(change);
    }

    @Override
    public NoteDbUpdateManager stage(ReviewDb db, Change.Id changeId)
            throws NoSuchChangeException, IOException, OrmException {
        db = ReviewDbUtil.unwrapDb(db);
        Change change = db.changes().get(changeId);
        if (change == null) {
            throw new NoSuchChangeException(changeId);
        }
        NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject());
        buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
        manager.stage();
        return manager;
    }

    @Override
    public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
            throws NoSuchChangeException, OrmException, IOException {
        db = ReviewDbUtil.unwrapDb(db);
        Change change = db.changes().get(changeId);
        if (change == null) {
            throw new NoSuchChangeException(changeId);
        }

        final String oldNoteDbState = change.getNoteDbState();
        Result r = manager.stageAndApplyDelta(change);
        final String newNoteDbState = change.getNoteDbState();
        try {
            db.changes().atomicUpdate(changeId, new AtomicUpdate<Change>() {
                @Override
                public Change update(Change change) {
                    String currNoteDbState = change.getNoteDbState();
                    if (Objects.equals(currNoteDbState, newNoteDbState)) {
                        // Another thread completed the same rebuild we were about to.
                        throw new AbortUpdateException();
                    } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
                        // Another thread updated the state to something else.
                        throw new ConflictingUpdateException(change, oldNoteDbState);
                    }
                    change.setNoteDbState(newNoteDbState);
                    return change;
                }
            });
        } catch (ConflictingUpdateException e) {
            // Rethrow as an OrmException so the caller knows to use staged results.
            // Strictly speaking they are not completely up to date, but result we
            // send to the caller is the same as if this rebuild had executed before
            // the other thread.
            throw new OrmException(e.getMessage());
        } catch (AbortUpdateException e) {
            if (NoteDbChangeState.parse(changeId, newNoteDbState).isUpToDate(
                    manager.getChangeRepo().cmds.getRepoRefCache(),
                    manager.getAllUsersRepo().cmds.getRepoRefCache())) {
                // If the state in ReviewDb matches NoteDb at this point, it means
                // another thread successfully completed this rebuild. It's ok to not
                // execute the update in this case, since the object referenced in the
                // Result was flushed to the repo by whatever thread won the race.
                return r;
            }
            // If the state doesn't match, that means another thread attempted this
            // rebuild, but failed. Fall through and try to update the ref again.
        }
        if (migration.failChangeWrites()) {
            // 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);
        }
        manager.execute();
        return r;
    }

    @Override
    public boolean rebuildProject(ReviewDb db, ImmutableMultimap<Project.NameKey, Change.Id> allChanges,
            Project.NameKey project, Repository allUsersRepo)
            throws NoSuchChangeException, IOException, OrmException, ConfigInvalidException {
        checkArgument(allChanges.containsKey(project));
        boolean ok = true;
        ProgressMonitor pm = new TextProgressMonitor(new PrintWriter(System.out));
        pm.beginTask(FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
        try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
                ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
                RevWalk allUsersRw = new RevWalk(allUsersInserter.newReader())) {
            manager.setAllUsersRepo(allUsersRepo, allUsersRw, allUsersInserter,
                    new ChainedReceiveCommands(allUsersRepo));
            for (Change.Id changeId : allChanges.get(project)) {
                try {
                    buildUpdates(manager, ChangeBundle.fromReviewDb(db, changeId));
                } catch (Throwable t) {
                    log.error("Failed to rebuild change " + changeId, t);
                    ok = false;
                }
                pm.update(1);
            }
            manager.execute();
        } finally {
            pm.endTask();
        }
        return ok;
    }

    private void buildUpdates(NoteDbUpdateManager manager, ChangeBundle bundle) throws IOException, OrmException {
        manager.setCheckExpectedState(false);
        Change change = new Change(bundle.getChange());
        PatchSet.Id currPsId = change.currentPatchSetId();
        // We will rebuild all events, except for draft comments, in buckets based
        // on author and timestamp.
        List<Event> events = new ArrayList<>();
        Multimap<Account.Id, PatchLineCommentEvent> draftCommentEvents = ArrayListMultimap.create();

        events.addAll(getHashtagsEvents(change, manager));

        // Delete ref only after hashtags have been read
        deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
        deleteDraftRefs(change, manager.getAllUsersRepo());

        Integer minPsNum = getMinPatchSetNum(bundle);
        Set<PatchSet.Id> psIds = Sets.newHashSetWithExpectedSize(bundle.getPatchSets().size());

        for (PatchSet ps : bundle.getPatchSets()) {
            if (ps.getId().get() > currPsId.get()) {
                log.info("Skipping patch set {}, which is higher than current patch set {}", ps.getId(), currPsId);
                continue;
            }
            psIds.add(ps.getId());
            events.add(new PatchSetEvent(change, ps, manager.getChangeRepo().rw));
            for (PatchLineComment c : getPatchLineComments(bundle, ps)) {
                PatchLineCommentEvent e = new PatchLineCommentEvent(c, change, ps, patchListCache);
                if (c.getStatus() == Status.PUBLISHED) {
                    events.add(e);
                } else {
                    draftCommentEvents.put(c.getAuthor(), e);
                }
            }
        }

        for (PatchSetApproval psa : bundle.getPatchSetApprovals()) {
            if (psIds.contains(psa.getPatchSetId())) {
                events.add(new ApprovalEvent(psa, change.getCreatedOn()));
            }
        }

        for (Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> r : bundle.getReviewers().asTable()
                .cellSet()) {
            events.add(new ReviewerEvent(r, change.getCreatedOn()));
        }

        Change noteDbChange = new Change(null, null, null, null, null);
        for (ChangeMessage msg : bundle.getChangeMessages()) {
            if (msg.getPatchSetId() == null || psIds.contains(msg.getPatchSetId())) {
                events.add(new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
            }
        }

        sortAndFillEvents(change, noteDbChange, events, minPsNum);

        EventList<Event> el = new EventList<>();
        for (Event e : events) {
            if (!el.canAdd(e)) {
                flushEventsToUpdate(manager, el, change);
                checkState(el.canAdd(e));
            }
            el.add(e);
        }
        flushEventsToUpdate(manager, el, change);

        EventList<PatchLineCommentEvent> plcel = new EventList<>();
        for (Account.Id author : draftCommentEvents.keys()) {
            for (PatchLineCommentEvent e : EVENT_ORDER.sortedCopy(draftCommentEvents.get(author))) {
                if (!plcel.canAdd(e)) {
                    flushEventsToDraftUpdate(manager, plcel, change);
                    checkState(plcel.canAdd(e));
                }
                plcel.add(e);
            }
            flushEventsToDraftUpdate(manager, plcel, change);
        }
    }

    private static Integer getMinPatchSetNum(ChangeBundle bundle) {
        Integer minPsNum = null;
        for (PatchSet ps : bundle.getPatchSets()) {
            int n = ps.getId().get();
            if (minPsNum == null || n < minPsNum) {
                minPsNum = n;
            }
        }
        return minPsNum;
    }

    private static List<PatchLineComment> getPatchLineComments(ChangeBundle bundle, final PatchSet ps) {
        return FluentIterable.from(bundle.getPatchLineComments()).filter(new Predicate<PatchLineComment>() {
            @Override
            public boolean apply(PatchLineComment in) {
                return in.getPatchSetId().equals(ps.getId());
            }
        }).toSortedList(PatchLineCommentsUtil.PLC_ORDER);
    }

    private void sortAndFillEvents(Change change, Change noteDbChange, List<Event> events, Integer minPsNum) {
        Collections.sort(events, EVENT_ORDER);
        events.add(new FinalUpdatesEvent(change, noteDbChange));

        // Ensure the first event in the list creates the change, setting the author
        // and any required footers.
        Event first = events.get(0);
        if (first instanceof PatchSetEvent && change.getOwner().equals(first.who)) {
            ((PatchSetEvent) first).createChange = true;
        } else {
            events.add(0, new CreateChangeEvent(change, minPsNum));
        }

        // Fill in any missing patch set IDs using the latest patch set of the
        // change at the time of the event, because NoteDb can't represent actions
        // with no associated patch set ID. This workaround is as if a user added a
        // ChangeMessage on the change by replying from the latest patch set.
        //
        // Start with the first patch set that actually exists. If there are no
        // patch sets at all, minPsNum will be null, so just bail and use 1 as the
        // patch set ID. The corresponding patch set won't exist, but this change is
        // probably corrupt anyway, as deleting the last draft patch set should have
        // deleted the whole change.
        int ps = firstNonNull(minPsNum, 1);
        for (Event e : events) {
            if (e.psId == null) {
                e.psId = new PatchSet.Id(change.getId(), ps);
            } else {
                ps = Math.max(ps, e.psId.get());
            }
        }
    }

    private void flushEventsToUpdate(NoteDbUpdateManager manager, EventList<Event> events, Change change)
            throws OrmException, IOException {
        if (events.isEmpty()) {
            return;
        }
        Comparator<String> labelNameComparator;
        if (projectCache != null) {
            labelNameComparator = projectCache.get(change.getProject()).getLabelTypes().nameComparator();
        } else {
            // No project cache available, bail and use natural ordering; there's no
            // semantic difference anyway difference.
            labelNameComparator = Ordering.natural();
        }
        ChangeUpdate update = updateFactory.create(change, events.getAccountId(), events.newAuthorIdent(),
                events.getWhen(), labelNameComparator);
        update.setAllowWriteToNewRef(true);
        update.setPatchSetId(events.getPatchSetId());
        update.setTag(events.getTag());
        for (Event e : events) {
            e.apply(update);
        }
        manager.add(update);
        events.clear();
    }

    private void flushEventsToDraftUpdate(NoteDbUpdateManager manager, EventList<PatchLineCommentEvent> events,
            Change change) throws OrmException {
        if (events.isEmpty()) {
            return;
        }
        ChangeDraftUpdate update = draftUpdateFactory.create(change, events.getAccountId(), events.newAuthorIdent(),
                events.getWhen());
        update.setPatchSetId(events.getPatchSetId());
        for (PatchLineCommentEvent e : events) {
            e.applyDraft(update);
        }
        manager.add(update);
        events.clear();
    }

    private List<HashtagsEvent> getHashtagsEvents(Change change, NoteDbUpdateManager manager) throws IOException {
        String refName = changeMetaRef(change.getId());
        Optional<ObjectId> old = manager.getChangeRepo().getObjectId(refName);
        if (!old.isPresent()) {
            return Collections.emptyList();
        }

        RevWalk rw = manager.getChangeRepo().rw;
        List<HashtagsEvent> events = new ArrayList<>();
        rw.reset();
        rw.markStart(rw.parseCommit(old.get()));
        for (RevCommit commit : rw) {
            Account.Id authorId;
            try {
                authorId = changeNoteUtil.parseIdent(commit.getAuthorIdent(), change.getId());
            } catch (ConfigInvalidException e) {
                continue; // Corrupt data, no valid hashtags in this commit.
            }
            PatchSet.Id psId = parsePatchSetId(change, commit);
            Set<String> hashtags = parseHashtags(commit);
            if (authorId == null || psId == null || hashtags == null) {
                continue;
            }

            Timestamp commitTime = new Timestamp(commit.getCommitterIdent().getWhen().getTime());
            events.add(new HashtagsEvent(psId, authorId, commitTime, hashtags, change.getCreatedOn()));
        }
        return events;
    }

    private Set<String> parseHashtags(RevCommit commit) {
        List<String> hashtagsLines = commit.getFooterLines(FOOTER_HASHTAGS);
        if (hashtagsLines.isEmpty() || hashtagsLines.size() > 1) {
            return null;
        }

        if (hashtagsLines.get(0).isEmpty()) {
            return ImmutableSet.of();
        }
        return Sets.newHashSet(Splitter.on(',').split(hashtagsLines.get(0)));
    }

    private PatchSet.Id parsePatchSetId(Change change, RevCommit commit) {
        List<String> psIdLines = commit.getFooterLines(FOOTER_PATCH_SET);
        if (psIdLines.size() != 1) {
            return null;
        }
        Integer psId = Ints.tryParse(psIdLines.get(0));
        if (psId == null) {
            return null;
        }
        return new PatchSet.Id(change.getId(), psId);
    }

    private void deleteChangeMetaRef(Change change, ChainedReceiveCommands cmds) throws IOException {
        String refName = changeMetaRef(change.getId());
        Optional<ObjectId> old = cmds.get(refName);
        if (old.isPresent()) {
            cmds.add(new ReceiveCommand(old.get(), ObjectId.zeroId(), refName));
        }
    }

    private void deleteDraftRefs(Change change, OpenRepo allUsersRepo) throws IOException {
        for (Ref r : allUsersRepo.repo.getRefDatabase().getRefs(RefNames.refsDraftCommentsPrefix(change.getId()))
                .values()) {
            allUsersRepo.cmds.add(new ReceiveCommand(r.getObjectId(), ObjectId.zeroId(), r.getName()));
        }
    }

    private static final Ordering<Event> EVENT_ORDER = new Ordering<Event>() {
        @Override
        public int compare(Event a, Event b) {
            return ComparisonChain.start().compare(a.when, b.when).compareTrueFirst(isPatchSet(a), isPatchSet(b))
                    .compareTrueFirst(a.predatesChange, b.predatesChange)
                    .compare(a.who, b.who, ReviewDbUtil.intKeyOrdering())
                    .compare(a.psId, b.psId, ReviewDbUtil.intKeyOrdering().nullsLast()).result();
        }

        private boolean isPatchSet(Event e) {
            return e instanceof PatchSetEvent;
        }
    };

    private abstract static class Event {
        // NOTE: EventList only supports direct subclasses, not an arbitrary
        // hierarchy.

        final Account.Id who;
        final Timestamp when;
        final String tag;
        final boolean predatesChange;
        PatchSet.Id psId;

        protected Event(PatchSet.Id psId, Account.Id who, Timestamp when, Timestamp changeCreatedOn, String tag) {
            this.psId = psId;
            this.who = who;
            this.tag = tag;
            // Truncate timestamps at the change's createdOn timestamp.
            predatesChange = when.before(changeCreatedOn);
            this.when = predatesChange ? changeCreatedOn : when;
        }

        protected void checkUpdate(AbstractChangeUpdate update) {
            checkState(Objects.equals(update.getPatchSetId(), psId), "cannot apply event for %s to update for %s",
                    update.getPatchSetId(), psId);
            checkState(when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
                    "event at %s outside update window starting at %s", when, update.getWhen());
            checkState(Objects.equals(update.getNullableAccountId(), who),
                    "cannot apply event by %s to update by %s", who, update.getNullableAccountId());
        }

        /**
         * @return whether this event type must be unique per {@link ChangeUpdate},
         *     i.e. there may be at most one of this type.
         */
        abstract boolean uniquePerUpdate();

        abstract void apply(ChangeUpdate update) throws OrmException, IOException;

        @Override
        public String toString() {
            return MoreObjects.toStringHelper(this).add("psId", psId).add("who", who).add("when", when).toString();
        }
    }

    private class EventList<E extends Event> extends ArrayList<E> {
        private static final long serialVersionUID = 1L;

        private E getLast() {
            return get(size() - 1);
        }

        private long getLastTime() {
            return getLast().when.getTime();
        }

        private long getFirstTime() {
            return get(0).when.getTime();
        }

        boolean canAdd(E e) {
            if (isEmpty()) {
                return true;
            }
            if (e instanceof FinalUpdatesEvent) {
                return false; // FinalUpdatesEvent always gets its own update.
            }

            Event last = getLast();
            if (!Objects.equals(e.who, last.who) || !e.psId.equals(last.psId) || !Objects.equals(e.tag, last.tag)) {
                return false; // Different patch set, author, or tag.
            }

            long t = e.when.getTime();
            long tFirst = getFirstTime();
            long tLast = getLastTime();
            checkArgument(t >= tLast, "event %s is before previous event in list %s", e, last);
            if (t - tLast > MAX_DELTA_MS || t - tFirst > MAX_WINDOW_MS) {
                return false; // Too much time elapsed.
            }

            if (!e.uniquePerUpdate()) {
                return true;
            }
            for (Event o : this) {
                if (e.getClass() == o.getClass()) {
                    return false; // Only one event of this type allowed per update.
                }
            }

            // TODO(dborowitz): Additional heuristics, like keeping events separate if
            // they affect overlapping fields within a single entity.

            return true;
        }

        Timestamp getWhen() {
            return get(0).when;
        }

        PatchSet.Id getPatchSetId() {
            PatchSet.Id id = checkNotNull(get(0).psId);
            for (int i = 1; i < size(); i++) {
                checkState(get(i).psId.equals(id), "mismatched patch sets in EventList: %s != %s", id, get(i).psId);
            }
            return id;
        }

        Account.Id getAccountId() {
            Account.Id id = get(0).who;
            for (int i = 1; i < size(); i++) {
                checkState(Objects.equals(id, get(i).who), "mismatched users in EventList: %s != %s", id,
                        get(i).who);
            }
            return id;
        }

        PersonIdent newAuthorIdent() {
            Account.Id id = getAccountId();
            if (id == null) {
                return new PersonIdent(serverIdent, getWhen());
            }
            return changeNoteUtil.newIdent(accountCache.get(id).getAccount(), getWhen(), serverIdent,
                    anonymousCowardName);
        }

        String getTag() {
            return getLast().tag;
        }
    }

    private static void createChange(ChangeUpdate update, Change change) {
        update.setSubjectForCommit("Create change");
        update.setChangeId(change.getKey().get());
        update.setBranch(change.getDest().get());
        update.setSubject(change.getOriginalSubject());
    }

    private static class CreateChangeEvent extends Event {
        private final Change change;

        private static PatchSet.Id psId(Change change, Integer minPsNum) {
            int n;
            if (minPsNum == null) {
                // There were no patch sets for the change at all, so something is very
                // wrong. Bail and use 1 as the patch set.
                n = 1;
            } else {
                n = minPsNum;
            }
            return new PatchSet.Id(change.getId(), n);
        }

        CreateChangeEvent(Change change, Integer minPsNum) {
            super(psId(change, minPsNum), change.getOwner(), change.getCreatedOn(), change.getCreatedOn(), null);
            this.change = change;
        }

        @Override
        boolean uniquePerUpdate() {
            return true;
        }

        @Override
        void apply(ChangeUpdate update) throws IOException, OrmException {
            checkUpdate(update);
            createChange(update, change);
        }
    }

    private static class ApprovalEvent extends Event {
        private PatchSetApproval psa;

        ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
            super(psa.getPatchSetId(), psa.getAccountId(), psa.getGranted(), changeCreatedOn, psa.getTag());
            this.psa = psa;
        }

        @Override
        boolean uniquePerUpdate() {
            return false;
        }

        @Override
        void apply(ChangeUpdate update) {
            checkUpdate(update);
            update.putApproval(psa.getLabel(), psa.getValue());
        }
    }

    private static class ReviewerEvent extends Event {
        private Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer;

        ReviewerEvent(Table.Cell<ReviewerStateInternal, Account.Id, Timestamp> reviewer,
                Timestamp changeCreatedOn) {
            super(
                    // Reviewers aren't generally associated with a particular patch set
                    // (although as an implementation detail they were in ReviewDb). Just
                    // use the latest patch set at the time of the event.
                    null, reviewer.getColumnKey(), reviewer.getValue(), changeCreatedOn, null);
            this.reviewer = reviewer;
        }

        @Override
        boolean uniquePerUpdate() {
            return false;
        }

        @Override
        void apply(ChangeUpdate update) throws IOException, OrmException {
            checkUpdate(update);
            update.putReviewer(reviewer.getColumnKey(), reviewer.getRowKey());
        }
    }

    private static class PatchSetEvent extends Event {
        private final Change change;
        private final PatchSet ps;
        private final RevWalk rw;
        private boolean createChange;

        PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
            super(ps.getId(), ps.getUploader(), ps.getCreatedOn(), change.getCreatedOn(), null);
            this.change = change;
            this.ps = ps;
            this.rw = rw;
        }

        @Override
        boolean uniquePerUpdate() {
            return true;
        }

        @Override
        void apply(ChangeUpdate update) throws IOException, OrmException {
            checkUpdate(update);
            if (createChange) {
                createChange(update, change);
            } else {
                update.setSubject(change.getSubject());
                update.setSubjectForCommit("Create patch set " + ps.getPatchSetId());
            }
            setRevision(update, ps);
            List<String> groups = ps.getGroups();
            if (!groups.isEmpty()) {
                update.setGroups(ps.getGroups());
            }
            if (ps.isDraft()) {
                update.setPatchSetState(PatchSetState.DRAFT);
            }
        }

        private void setRevision(ChangeUpdate update, PatchSet ps) throws IOException {
            String rev = ps.getRevision().get();
            String cert = ps.getPushCertificate();
            ObjectId id;
            try {
                id = ObjectId.fromString(rev);
            } catch (InvalidObjectIdException e) {
                update.setRevisionForMissingCommit(rev, cert);
                return;
            }
            try {
                update.setCommit(rw, id, cert);
            } catch (MissingObjectException e) {
                update.setRevisionForMissingCommit(rev, cert);
                return;
            }
        }
    }

    private static class PatchLineCommentEvent extends Event {
        public final PatchLineComment c;
        private final Change change;
        private final PatchSet ps;
        private final PatchListCache cache;

        PatchLineCommentEvent(PatchLineComment c, Change change, PatchSet ps, PatchListCache cache) {
            super(PatchLineCommentsUtil.getCommentPsId(c), c.getAuthor(), c.getWrittenOn(), change.getCreatedOn(),
                    c.getTag());
            this.c = c;
            this.change = change;
            this.ps = ps;
            this.cache = cache;
        }

        @Override
        boolean uniquePerUpdate() {
            return false;
        }

        @Override
        void apply(ChangeUpdate update) throws OrmException {
            checkUpdate(update);
            if (c.getRevId() == null) {
                setCommentRevId(c, cache, change, ps);
            }
            update.putComment(c);
        }

        void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
            if (c.getRevId() == null) {
                setCommentRevId(c, cache, change, ps);
            }
            draftUpdate.putComment(c);
        }
    }

    private static class HashtagsEvent extends Event {
        private final Set<String> hashtags;

        HashtagsEvent(PatchSet.Id psId, Account.Id who, Timestamp when, Set<String> hashtags,
                Timestamp changeCreatdOn) {
            super(psId, who, when, changeCreatdOn,
                    // Somewhat confusingly, hashtags do not use the setTag method on
                    // AbstractChangeUpdate, so pass null as the tag.
                    null);
            this.hashtags = hashtags;
        }

        @Override
        boolean uniquePerUpdate() {
            // Since these are produced from existing commits in the old NoteDb graph,
            // we know that there must be one per commit in the rebuilt graph.
            return true;
        }

        @Override
        void apply(ChangeUpdate update) throws OrmException {
            update.setHashtags(hashtags);
        }
    }

    private static class ChangeMessageEvent extends Event {
        private static final Pattern TOPIC_SET_REGEXP = Pattern.compile("^Topic set to (.+)$");
        private static final Pattern TOPIC_CHANGED_REGEXP = Pattern.compile("^Topic changed from (.+) to (.+)$");
        private static final Pattern TOPIC_REMOVED_REGEXP = Pattern.compile("^Topic (.+) removed$");

        private static final Pattern STATUS_ABANDONED_REGEXP = Pattern.compile("^Abandoned(\n.*)*$");
        private static final Pattern STATUS_RESTORED_REGEXP = Pattern.compile("^Restored(\n.*)*$");

        private final ChangeMessage message;
        private final Change noteDbChange;

        ChangeMessageEvent(ChangeMessage message, Change noteDbChange, Timestamp changeCreatedOn) {
            super(message.getPatchSetId(), message.getAuthor(), message.getWrittenOn(), changeCreatedOn,
                    message.getTag());
            this.message = message;
            this.noteDbChange = noteDbChange;
        }

        @Override
        boolean uniquePerUpdate() {
            return true;
        }

        @Override
        void apply(ChangeUpdate update) throws OrmException {
            checkUpdate(update);
            update.setChangeMessage(message.getMessage());
            setTopic(update);
            setStatus(update);
        }

        private void setTopic(ChangeUpdate update) {
            String msg = message.getMessage();
            if (msg == null) {
                return;
            }
            Matcher m = TOPIC_SET_REGEXP.matcher(msg);
            if (m.matches()) {
                String topic = m.group(1);
                update.setTopic(topic);
                noteDbChange.setTopic(topic);
                return;
            }

            m = TOPIC_CHANGED_REGEXP.matcher(msg);
            if (m.matches()) {
                String topic = m.group(2);
                update.setTopic(topic);
                noteDbChange.setTopic(topic);
                return;
            }

            if (TOPIC_REMOVED_REGEXP.matcher(msg).matches()) {
                update.setTopic(null);
                noteDbChange.setTopic(null);
            }
        }

        private void setStatus(ChangeUpdate update) {
            String msg = message.getMessage();
            if (msg == null) {
                return;
            }
            if (STATUS_ABANDONED_REGEXP.matcher(msg).matches()) {
                update.setStatus(Change.Status.ABANDONED);
                noteDbChange.setStatus(Change.Status.ABANDONED);
                return;
            }

            if (STATUS_RESTORED_REGEXP.matcher(msg).matches()) {
                update.setStatus(Change.Status.NEW);
                noteDbChange.setStatus(Change.Status.NEW);
            }
        }
    }

    private static class FinalUpdatesEvent extends Event {
        private final Change change;
        private final Change noteDbChange;

        FinalUpdatesEvent(Change change, Change noteDbChange) {
            super(change.currentPatchSetId(), change.getOwner(), change.getLastUpdatedOn(), change.getCreatedOn(),
                    null);
            this.change = change;
            this.noteDbChange = noteDbChange;
        }

        @Override
        boolean uniquePerUpdate() {
            return true;
        }

        @SuppressWarnings("deprecation")
        @Override
        void apply(ChangeUpdate update) throws OrmException {
            if (!Objects.equals(change.getTopic(), noteDbChange.getTopic())) {
                update.setTopic(change.getTopic());
            }
            if (!Objects.equals(change.getStatus(), noteDbChange.getStatus())) {
                // TODO(dborowitz): Stamp approximate approvals at this time.
                update.fixStatus(change.getStatus());
            }
            if (change.getSubmissionId() != null) {
                update.setSubmissionId(change.getSubmissionId());
            }
            if (!update.isEmpty()) {
                update.setSubjectForCommit("Final NoteDb migration updates");
            }
        }
    }
}