Merge branch 'stable-3.4'

* stable-3.4: (24 commits)
  ParsingQueuePersistenceIT: fix google-common imports
  EiffelEventParser: fix javadoc
  Persist failed tasks
  ParsingQueue: Save failed parsing-tasks
  Keep queued tasks to persist in a Set and not a List
  Correct spelling persistance -> persistence
  ParsingQueue: Move scheduling logic into worker
  Fix EiffelEventParserIt#artcQueued
  Refactor EiffelEventParser and implementations
  EiffelConfig: fix invalid log format
  EventKey: add missing cases to switch/case
  Fix event-name in routing key
  Persist parsing-queue on plugin stop
  EiffelEventParsingQueue: Google-java-format
  Refactor EiffelEventParserIT
  Make EiffelEventParsingQueue#scheduleArtcCreation private
  Extract private method to schedule SCS from submit-event
  Rename EiffelEventParser and EiffelEventParserIf
  Extract interface from EiffelEventParser
  Extract the task description from EventParsingWorker
  ...

Change-Id: I8d6f26aef920b9b7ccd967a5f7f164a02b9c6650
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Manager.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Manager.java
index 519698c..16d61c3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Manager.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Manager.java
@@ -31,6 +31,7 @@
   @Override
   public void start() {
     eventHub.startPublishing();
+    parsingQueue.init();
   }
 
   @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Module.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Module.java
index 28649a0..9bdc0c4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/Module.java
@@ -39,6 +39,8 @@
 import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventFactory;
 import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventMapper;
 import com.googlesource.gerrit.plugins.eventseiffel.mq.RabbitMqPublisher;
+import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser;
+import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParserImpl;
 import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParsingExecutor;
 import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParsingQueue;
 import com.googlesource.gerrit.plugins.eventseiffel.rest.RestModule;
@@ -98,6 +100,7 @@
         .in(Scopes.SINGLETON);
     bind(EiffelEventParsingExecutor.Scheduled.class).in(Scopes.SINGLETON);
     bind(EiffelEventParsingExecutor.class).to(EiffelEventParsingExecutor.Scheduled.class);
+    bind(EiffelEventParser.class).to(EiffelEventParserImpl.class);
     bind(EiffelEventParsingQueue.class).in(Scopes.SINGLETON);
     bind(EiffelEventHubImpl.class).in(Scopes.SINGLETON);
     bind(EiffelEventHub.class).to(EiffelEventHubImpl.class);
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/config/EiffelConfig.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/config/EiffelConfig.java
index e04401a..7d43311 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/config/EiffelConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/config/EiffelConfig.java
@@ -51,7 +51,7 @@
       logger.atInfo().log("namespace is not set in the config");
     } else {
       this.namespace = Optional.of(namespace);
-      logger.atInfo().log("namespace is set to: %d", namespace);
+      logger.atInfo().log("namespace is set to: %s", namespace);
     }
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/EventKey.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/EventKey.java
index fe582d4..a8ca972 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/EventKey.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/EventKey.java
@@ -69,6 +69,8 @@
       case ARTC:
       case CD:
         return true;
+      case SCC:
+      case SCS:
       default:
         return false;
     }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/dto/EiffelEventType.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/dto/EiffelEventType.java
index 6ea9a27..27b3ee4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/dto/EiffelEventType.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/eiffel/dto/EiffelEventType.java
@@ -18,11 +18,11 @@
 
 public enum EiffelEventType {
   @SerializedName("EiffelSourceChangeCreatedEvent")
-  SCC("EiffelSourceChangeCreated"),
+  SCC("EiffelSourceChangeCreatedEvent"),
   @SerializedName("EiffelSourceChangeSubmittedEvent")
-  SCS("EiffelSourceChangeSubmitted"),
+  SCS("EiffelSourceChangeSubmittedEvent"),
   @SerializedName("EiffelArtifactCreatedEvent")
-  ARTC("EiffelArtifactCreated"),
+  ARTC("EiffelArtifactCreatedEvent"),
   @SerializedName("EiffelCompositionDefinedEvent")
   CD("EiffelCompositionDefinedEvent");
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParser.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParser.java
index d5d3ff0..faa1fed 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParser.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParser.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2021 The Android Open Source Project
+// Copyright (C) 2022 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.
@@ -11,451 +11,81 @@
 // 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.googlesource.gerrit.plugins.eventseiffel.parsing;
 
-import static com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType.SCC;
-
-import com.github.rholder.retry.RetryException;
-import com.github.rholder.retry.Retryer;
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.exceptions.NoSuchEntityException;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.events.RevisionCreatedListener.Event;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventHub;
-import com.googlesource.gerrit.plugins.eventseiffel.cache.EiffelEventIdLookupException;
-import com.googlesource.gerrit.plugins.eventseiffel.config.EventsFilter;
-import com.googlesource.gerrit.plugins.eventseiffel.eiffel.CompositionDefinedEventKey;
-import com.googlesource.gerrit.plugins.eventseiffel.eiffel.EventKey;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey;
-import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEvent;
-import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventMapper;
-import com.googlesource.gerrit.plugins.eventseiffel.parsing.UnprocessedCommitsWalker.EventCreate;
-import com.googlesource.gerrit.plugins.eventseiffel.parsing.UnprocessedCommitsWalker.ScsWalker;
-import java.io.IOException;
-import java.util.List;
-import java.util.Optional;
-import java.util.UUID;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import java.util.stream.Collectors;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.revwalk.RevWalkUtils;
 
-/** Creates and pushes missing Eiffel events to the Eiffel event queue. */
-public class EiffelEventParser {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private static final int NBR_RETRIES = 3;
-
-  private final EiffelEventHub eventHub;
-  private final GitRepositoryManager repoManager;
-  private final UnprocessedCommitsWalker.Factory walkerFactory;
-  private final EiffelEventMapper mapper;
-  private final Provider<EventsFilter> eventsFilter;
-
-  @Inject
-  public EiffelEventParser(
-      EiffelEventHub eventQueue,
-      GitRepositoryManager repoManager,
-      EiffelEventMapper mapper,
-      UnprocessedCommitsWalker.Factory walkerFactory,
-      Provider<EventsFilter> eventsFilter) {
-    this.eventHub = eventQueue;
-    this.repoManager = repoManager;
-    this.walkerFactory = walkerFactory;
-    this.mapper = mapper;
-    this.eventsFilter = eventsFilter;
-  }
-
-  public void createAndScheduleSccFromEvent(Event event) {
-    CommitInfo commit = event.getRevision().commit;
-    SourceChangeEventKey scc =
-        SourceChangeEventKey.sccKey(
-            event.getChange().project, event.getChange().branch, commit.commit);
-    try {
-      if (eventHub.getExistingId(scc).isPresent()) {
-        logger.atWarning().log(
-            "Event %s already pushed for %d/%d",
-            scc, event.getChange()._number, event.getRevision()._number);
-        return;
-      }
-      List<UUID> parentUuids = Lists.newArrayList();
-      for (CommitInfo parent : commit.parents) {
-        Optional<UUID> parentUuid = eventHub.getExistingId(scc.copy(parent.commit));
-        if (parentUuid.isPresent()) {
-          parentUuids.add(parentUuid.get());
-        }
-      }
-
-      /* Eiffel events have been scheduled or published for all parents. */
-      if (parentUuids.size() == commit.parents.size()) {
-        pushToHub(mapper.toScc(event, parentUuids));
-      } else {
-        createAndScheduleMissingSccs(scc);
-      }
-    } catch (IOException
-        | ConfigInvalidException
-        | NoSuchEntityException
-        | EiffelEventIdLookupException
-        | InterruptedException e) {
-      logger.atSevere().withCause(e).log(
-          "Event creation failed for: %s, %s, %s to SCC.",
-          event.getChange().project, event.getChange().branch, event.getRevision().commit.commit);
-    }
-  }
+public interface EiffelEventParser {
 
   /**
-   * Creates SCC events from a ref. ref can be a branch or a patch-set ref.
+   * Creates SCC events for all commits reachable from the commit that triggered the {@link Event}
+   * event.
    *
-   * @param repoName
-   * @param ref - schedule event creation for commits reachable from this ref.
-   * @param targetBranch - used to populate data.gitIdentifier.branch.
+   * @param event - Describes a revision from which to create SCC events.
+   * @throws EventParsingException- When event-creation fails.
    */
-  public void createAndScheduleSccFromRef(String repoName, String ref, String targetBranch) {
-    ObjectId tip = getTipOf(repoName, ref);
-    if (tip == null) {
-      return;
-    }
+  void createAndScheduleSccFromEvent(Event event) throws EventParsingException;
 
-    SourceChangeEventKey scc = SourceChangeEventKey.sccKey(repoName, targetBranch, tip.getName());
-    try {
-      createAndScheduleMissingSccs(scc);
-    } catch (IOException
-        | EiffelEventIdLookupException
-        | NoSuchEntityException
-        | ConfigInvalidException
-        | InterruptedException e) {
-      logger.atSevere().withCause(e).log("Event creation failed for: %s", scc);
-    }
-  }
+  /**
+   * Creates SCC events for all commits reachable from branchRef. I.e. create SCCs for already
+   * merged commit that are missing SCCs.
+   *
+   * @param repoName - Name of the repository where the branch exists.
+   * @param branchRef - Creates missing events for all commits reachable from branch.
+   * @throws EventParsingException - When event-creation fails.
+   */
+  void createAndScheduleSccFromBranch(String repoName, String branchRef)
+      throws EventParsingException;
 
-  public void createAndScheduleMissingScssFromBranch(String repoName, String branch) {
-    ObjectId tip = getTipOf(repoName, branch);
-    if (tip == null) {
-      return;
-    }
-    SourceChangeEventKey scs = SourceChangeEventKey.scsKey(repoName, branch, tip.getName());
-    createAndScheduleMissingScss(scs, null, null, null);
-  }
+  /**
+   * Creates SCC events for all commits reachable from commit.
+   *
+   * @param repoName - Name of the repository were the commits exists.
+   * @param branchRef - Ref of the branch to create events for.
+   * @param commit - Creates missing events for all commits reachable from commit.
+   * @throws EventParsingException - When event-creation fails.
+   */
+  void createAndScheduleSccFromCommit(String repoName, String branchRef, String commit)
+      throws EventParsingException;
 
-  public void createAndScheduleMissingScss(
+  /**
+   * Creates missing SCS events for repoName, branch.
+   *
+   * @param repoName - Name of the repository where the branch exists.
+   * @param branchRef - Creates missing events for all commits reachable from branchRef.
+   * @throws EventParsingException - When event-creation fails.
+   */
+  void createAndScheduleMissingScssFromBranch(String repoName, String branchRef)
+      throws EventParsingException;
+
+  /**
+   * Creates missing SCS events for a submit transaction. submitter and submittedAt is set for all
+   * events created within the submit transaction, i.e. not reachable from commitSha1TransactionEnd.
+   *
+   * @param scs - Key for the event that should be created, together with parents.
+   * @param commitSha1TransactionEnd - Tip before submit transaction.
+   * @param submitter - The submitter
+   * @param submittedAt - When the commit was submitted.
+   * @throws EventParsingException - When event-creation fails.
+   */
+  void createAndScheduleMissingScss(
       SourceChangeEventKey scs,
       String commitSha1TransactionEnd,
       AccountInfo submitter,
-      Long submittedAt) {
-    SourceChangeEventKey currentScs = scs;
-    SourceChangeEventKey scc = scs.copy(SCC);
-    try {
-      try (ScsWalker scsFinder =
-          walkerFactory.scsWalker(scs, commitSha1TransactionEnd, submitter, submittedAt)) {
+      Long submittedAt)
+      throws EventParsingException;
 
-        if (eventHub.getExistingId(scc).isEmpty()) {
-          /* One or several SCC events are missing, create them first */
-          try (UnprocessedCommitsWalker sccFinder = scsFinder.sccWalker()) {
-            createAndScheduleMissingSccs(scc, sccFinder);
-          }
-        }
-
-        if (!scsFinder.hasNext()) {
-          logger.atInfo().log("All events were already published for %s", scs);
-        } else {
-          logger.atFine().log("Start publishing events for: %s", scs);
-        }
-        while (scsFinder.hasNext()) {
-          EventCreate create = scsFinder.next();
-          currentScs = create.key;
-          Optional<UUID> sccId = eventHub.getExistingId(create.key.copy(SCC));
-          if (sccId.isEmpty()) {
-            throw new EiffelEventIdLookupException(
-                "Unable to find SCC event id: %s", create.key.copy(SCC));
-          }
-          List<UUID> scsParentEventIds = Lists.newArrayList();
-          for (RevCommit parent : create.commit.getParents()) {
-            Optional<UUID> parentUuid = eventHub.getExistingId(create.key.copy(parent.getName()));
-            if (parentUuid.isPresent()) {
-              scsParentEventIds.add(parentUuid.get());
-            } else {
-              exceptionForMissingParent(create, parent);
-            }
-          }
-          pushToHub(
-              mapper.toScs(
-                  create.commit,
-                  create.key.repo(),
-                  create.key.branch(),
-                  create.submitter,
-                  create.submittedAt,
-                  scsParentEventIds,
-                  sccId.get()));
-        }
-      }
-      logger.atFine().log("Done publishing events for: %s", scs);
-    } catch (IOException
-        | EiffelEventIdLookupException
-        | InterruptedException
-        | ConfigInvalidException
-        | NoSuchEntityException e) {
-      logger.atSevere().withCause(e).log("Failed to create Eiffel event(s) for %s.", currentScs);
-    }
-  }
-
-  public void createAndScheduleArtc(
-      String projectName, String tagName, Long creationTime, boolean force) {
-    try {
-      CompositionDefinedEventKey cd =
-          CompositionDefinedEventKey.create(mapper.tagCompositionName(projectName), tagName);
-      Optional<UUID> oldCdId = eventHub.getExistingId(cd);
-      if (oldCdId.isEmpty() || force) {
-        createAndScheduleCd(projectName, tagName, creationTime, force);
-        Optional<UUID> cdId = eventHub.getExistingId(cd);
-        if (cdId.isPresent() && !cdId.equals(oldCdId)) {
-          pushToHub(mapper.toArtc(projectName, tagName, creationTime, cdId.get()), force);
-          if (oldCdId.isPresent()) {
-            logger.atInfo().log(
-                "Event Artc has been forcibly created for: %s, %s", projectName, tagName);
-          } else {
-            logger.atFine().log("Event Artc has been created for: %s, %s", projectName, tagName);
-          }
-        }
-      } else {
-        /* Artc event has already been created */
-        logger.atWarning().log(
-            "Event Artc has already been created for: %s, %s", projectName, tagName);
-      }
-    } catch (EiffelEventIdLookupException | InterruptedException e) {
-      logger.atSevere().withCause(e).log(
-          "Event creation failed for: %s, %s to Artc", projectName, tagName);
-    }
-  }
-
-  private void createAndScheduleCd(
-      String projectName, String tagName, Long creationTime, boolean force) {
-    Optional<UUID> scsId = Optional.empty();
-    List<Ref> refs = null;
-
-    try {
-      EventsFilter filter = eventsFilter.get();
-      ObjectId objectId = peelTag(projectName, tagName, filter.blockLightWeightTags());
-      if (objectId == null) {
-        return;
-      }
-      String commitId = objectId.getName();
-
-      /* Check if an event for commit~master has been created. */
-      SourceChangeEventKey scs =
-          SourceChangeEventKey.scsKey(projectName, RefNames.REFS_HEADS + "master", commitId);
-      scsId = eventHub.getExistingId(scs);
-
-      if (scsId.isEmpty()) {
-        /* No event created for commit~master. Check if event is created for any
-        of the other branches. */
-        Retryer<Optional<UUID>> retryer =
-            RetryerBuilder.<Optional<UUID>>newBuilder()
-                .retryIfResult(Optional::isEmpty)
-                .withWaitStrategy(WaitStrategies.fixedWait(10, TimeUnit.SECONDS))
-                .withStopStrategy(StopStrategies.stopAfterAttempt(2))
-                .build();
-        try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
-          refs =
-              repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS).stream()
-                  .collect(Collectors.toList());
-        } catch (IOException e) {
-          logger.atSevere().withCause(e).log(
-              "Unable to get branches for: %s:%s", projectName, tagName);
-          return;
-        }
-        try {
-          List<String> branches =
-              refs.stream()
-                  .map(Ref::getName)
-                  .filter(branch -> !branch.equals(RefNames.REFS_HEADS + "master"))
-                  .collect(Collectors.toList());
-          scsId = retryer.call(() -> findSourceChangeEventKey(projectName, commitId, branches));
-        } catch (RetryException | ExecutionException e) {
-          logger.atWarning().withCause(e).log(
-              "Failed to find SCS for %s in %s when trying to create CD for tag %s",
-              commitId, projectName, tagName);
-        }
-      }
-
-      if (scsId.isEmpty()) {
-        /* No event has been created for the commit. Find any non-blocked branch that
-        commit is merged into and create SCS events for that branch. */
-        List<Ref> branches;
-        try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
-          RevWalk rw = new RevWalk(repo);
-          refs =
-              refs.stream()
-                  .filter(ref -> !filter.refIsBlocked(ref.getName()))
-                  .collect(Collectors.toList());
-          branches = RevWalkUtils.findBranchesReachableFrom(rw.parseCommit(objectId), rw, refs);
-        } catch (IOException e) {
-          logger.atSevere().withCause(e).log(
-              "Unable to get reachable branches for: %s:%s", projectName, tagName);
-          return;
-        }
-        if (branches.isEmpty()) {
-          logger.atWarning().log(
-              "Could not find any unblocked branch for SCS with: %s in %s so CD could not be created for tag %s",
-              commitId, projectName, tagName);
-          return;
-        }
-        String branch = branches.get(0).getName();
-        scs = SourceChangeEventKey.scsKey(projectName, branch, commitId);
-        createAndScheduleMissingScss(scs, null, null, null);
-        scsId = eventHub.getExistingId(scs);
-      }
-
-      if (scsId.isEmpty()) {
-        logger.atWarning().log(
-            "Could not find or create SCS for %s in %s so CD could not be created for tag %s",
-            commitId, projectName, tagName);
-        return;
-      }
-      pushToHub(mapper.toCd(projectName, tagName, creationTime, scsId.get()), force);
-    } catch (EiffelEventIdLookupException | InterruptedException e) {
-      logger.atSevere().withCause(e).log(
-          "Event creation failed for: %s",
-          CompositionDefinedEventKey.create(mapper.tagCompositionName(projectName), tagName));
-    }
-  }
-
-  private ObjectId peelTag(String projectName, String tagName, boolean blockLightWeightTags) {
-    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
-      Ref tagRef = repo.getRefDatabase().exactRef(Constants.R_TAGS + tagName);
-      if (tagRef != null) {
-        ObjectId peeled = repo.getRefDatabase().peel(tagRef).getPeeledObjectId();
-        if (peeled != null) return peeled;
-        if (!blockLightWeightTags) return tagRef.getObjectId();
-        logger.atInfo().log("Creation of CD is blocked for lightweight tags");
-        return null;
-      }
-      logger.atSevere().log("Cannot find tag: %s:%s", projectName, tagName);
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Unable to peel tag: %s:%s", projectName, tagName);
-    }
-    return null;
-  }
-
-  private Optional<UUID> findSourceChangeEventKey(
-      String projectName, String commitId, List<String> branches)
-      throws EiffelEventIdLookupException {
-    return eventHub.getScsForCommit(projectName, commitId, branches);
-  }
-
-  private void pushToHub(EiffelEvent toPush) throws InterruptedException {
-    pushToHub(toPush, false);
-  }
-
-  private void pushToHub(EiffelEvent toPush, boolean force) throws InterruptedException {
-    int failureCount = 0;
-    EventKey key = EventKey.fromEvent(toPush);
-    while (true) {
-      try {
-        eventHub.push(toPush, force);
-        logger.atFine().log("Successfully pushed %s to EventHub", key);
-        return;
-      } catch (InterruptedException e) {
-        if (!eventHub.isOpen()) {
-          logger.atInfo().log("EventHub is closed, aborting.");
-          throw e;
-        }
-        failureCount++;
-        if (failureCount < NBR_RETRIES) {
-          logger.atWarning().withCause(e).log(
-              "Interrupted while pushing %s to EventHub, attempt %d/%d",
-              key, failureCount, NBR_RETRIES);
-        } else {
-          throw e;
-        }
-      }
-    }
-  }
-
-  @VisibleForTesting
-  void createAndScheduleMissingSccs(SourceChangeEventKey scc)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          EiffelEventIdLookupException, RepositoryNotFoundException, NoSuchEntityException,
-          ConfigInvalidException, InterruptedException {
-    logger.atFine().log("Start publishing events for: %s", scc);
-    try (UnprocessedCommitsWalker commitFinder = walkerFactory.sccWalker(scc)) {
-      createAndScheduleMissingSccs(scc, commitFinder);
-    }
-    logger.atFine().log("Done publishing events for: %s", scc);
-  }
-
-  /* Callers are responsible for closing commitFinder. */
-  private void createAndScheduleMissingSccs(
-      SourceChangeEventKey scc, UnprocessedCommitsWalker commitFinder)
-      throws MissingObjectException, EiffelEventIdLookupException, IOException,
-          NoSuchEntityException, ConfigInvalidException, InterruptedException {
-    if (!commitFinder.hasNext()) {
-      logger.atInfo().log("All events were already published for %s", scc);
-    } else {
-      logger.atFine().log("Start publishing events for: %s", scc);
-    }
-    while (commitFinder.hasNext()) {
-      EventCreate job = commitFinder.next();
-      logger.atFine().log("Processing event-creation for: %s", job.key);
-      List<UUID> parentIds = Lists.newArrayList();
-      for (RevCommit parent : job.commit.getParents()) {
-        SourceChangeEventKey parentKey = scc.copy(parent.getName());
-        Optional<UUID> parentId = eventHub.getExistingId(parentKey);
-        if (parentId.isPresent()) {
-          parentIds.add(parentId.get());
-        } else {
-          exceptionForMissingParent(job, parent);
-        }
-      }
-      try {
-        pushToHub(mapper.toScc(job.commit, job.key.repo(), job.key.branch(), parentIds));
-      } catch (InterruptedException e) {
-        logger.atSevere().log("Interrupted while pushing %s to EventHub.", job.key);
-        throw e;
-      }
-    }
-  }
-
-  private ObjectId getTipOf(String repoName, String branch) {
-    try (Repository repo = repoManager.openRepository(Project.nameKey(repoName))) {
-      Ref branchRef = repo.exactRef(branch);
-      if (branchRef == null) {
-        logger.atWarning().log("Could not find ref: %s in project: %s", branch, repoName);
-        return null;
-      }
-      return branchRef.getTarget().getObjectId();
-    } catch (IOException ioe) {
-      logger.atSevere().withCause(ioe).log("Unable to identify tip of (%s:%s).", repoName, branch);
-      return null;
-    }
-  }
-
-  private void exceptionForMissingParent(EventCreate create, RevCommit parent)
-      throws NoSuchEntityException {
-    throw new NoSuchEntityException(
-        String.format(
-            "Unable to lookup parent (%s) event UUID for %s even though it should exist.",
-            parent.abbreviate(7).name(), create.key));
-  }
+  /**
+   * Creates missing ARTC for a tag, together with CD and (if missing) SCS for referenced commit.
+   *
+   * @param repoName - Name of the repository were the tag exists.
+   * @param tagName - The name of the tag.
+   * @param creationTime - The time at which the tag was created.
+   * @param force - Whether existing events should be replaced or not.
+   * @throws EventParsingException - When event-creation fails.
+   */
+  void createAndScheduleArtc(String repoName, String tagName, Long creationTime, boolean force)
+      throws EventParsingException;
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserImpl.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserImpl.java
new file mode 100644
index 0000000..4e71eb1
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserImpl.java
@@ -0,0 +1,488 @@
+// Copyright (C) 2021 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import static com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType.SCC;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.exceptions.NoSuchEntityException;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener.Event;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventHub;
+import com.googlesource.gerrit.plugins.eventseiffel.cache.EiffelEventIdLookupException;
+import com.googlesource.gerrit.plugins.eventseiffel.config.EventsFilter;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.CompositionDefinedEventKey;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.EventKey;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEvent;
+import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventMapper;
+import com.googlesource.gerrit.plugins.eventseiffel.parsing.UnprocessedCommitsWalker.EventCreate;
+import com.googlesource.gerrit.plugins.eventseiffel.parsing.UnprocessedCommitsWalker.ScsWalker;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.revwalk.RevWalkUtils;
+
+/** Creates and pushes missing Eiffel events to the Eiffel event queue. */
+public class EiffelEventParserImpl implements EiffelEventParser {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int NBR_RETRIES = 3;
+
+  private final EiffelEventHub eventHub;
+  private final GitRepositoryManager repoManager;
+  private final UnprocessedCommitsWalker.Factory walkerFactory;
+  private final EiffelEventMapper mapper;
+  private final Provider<EventsFilter> eventsFilter;
+
+  @Inject
+  public EiffelEventParserImpl(
+      EiffelEventHub eventQueue,
+      GitRepositoryManager repoManager,
+      EiffelEventMapper mapper,
+      UnprocessedCommitsWalker.Factory walkerFactory,
+      Provider<EventsFilter> eventsFilter) {
+    this.eventHub = eventQueue;
+    this.repoManager = repoManager;
+    this.walkerFactory = walkerFactory;
+    this.mapper = mapper;
+    this.eventsFilter = eventsFilter;
+  }
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser#createAndScheduleSccFromEvent(com.google.gerrit.extensions.events.RevisionCreatedListener.Event)
+   */
+  @Override
+  public void createAndScheduleSccFromEvent(Event event) throws EventParsingException {
+    CommitInfo commit = event.getRevision().commit;
+    SourceChangeEventKey scc =
+        SourceChangeEventKey.sccKey(
+            event.getChange().project, event.getChange().branch, commit.commit);
+    try {
+      if (eventHub.getExistingId(scc).isPresent()) {
+        logger.atWarning().log(
+            "Event %s already pushed for %d/%d",
+            scc, event.getChange()._number, event.getRevision()._number);
+        return;
+      }
+      List<UUID> parentUuids = Lists.newArrayList();
+      for (CommitInfo parent : commit.parents) {
+        Optional<UUID> parentUuid = eventHub.getExistingId(scc.copy(parent.commit));
+        if (parentUuid.isPresent()) {
+          parentUuids.add(parentUuid.get());
+        }
+      }
+
+      /* Eiffel events have been scheduled or published for all parents. */
+      if (parentUuids.size() == commit.parents.size()) {
+        pushToHub(mapper.toScc(event, parentUuids));
+      } else {
+        createAndScheduleMissingSccs(scc);
+      }
+    } catch (IOException
+        | ConfigInvalidException
+        | NoSuchEntityException
+        | EiffelEventIdLookupException
+        | InterruptedException e) {
+      throw new EventParsingException(
+          e,
+          "Event creation failed for: %s, %s, %s to SCC.",
+          event.getChange().project,
+          event.getChange().branch,
+          event.getRevision().commit.commit);
+    }
+  }
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser#createAndScheduleSccFromBranch(java.lang.String, java.lang.String)
+   */
+  @Override
+  public void createAndScheduleSccFromBranch(String repoName, String branchRef)
+      throws EventParsingException {
+    ObjectId tip = getTipOf(repoName, branchRef);
+    if (tip == null) {
+      return;
+    }
+    createAndScheduleSccFromCommit(repoName, branchRef, tip.getName());
+  }
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser#createAndScheduleSccFromCommit(java.lang.String, java.lang.String, java.lang.String)
+   */
+  @Override
+  public void createAndScheduleSccFromCommit(String repoName, String branchRef, String commit)
+      throws EventParsingException {
+
+    SourceChangeEventKey scc = SourceChangeEventKey.sccKey(repoName, branchRef, commit);
+    try {
+      createAndScheduleMissingSccs(scc);
+    } catch (IOException
+        | EiffelEventIdLookupException
+        | NoSuchEntityException
+        | ConfigInvalidException
+        | InterruptedException e) {
+      throw new EventParsingException(e, "Event creation failed for: %s", scc);
+    }
+  }
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser#createAndScheduleMissingScssFromBranch(java.lang.String, java.lang.String)
+   */
+  @Override
+  public void createAndScheduleMissingScssFromBranch(String repoName, String branchRef)
+      throws EventParsingException {
+    ObjectId tip = getTipOf(repoName, branchRef);
+    if (tip == null) {
+      return;
+    }
+    SourceChangeEventKey scs = SourceChangeEventKey.scsKey(repoName, branchRef, tip.getName());
+    createAndScheduleMissingScss(scs, null, null, null);
+  }
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser#createAndScheduleMissingScss(com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey, java.lang.String, com.google.gerrit.extensions.common.AccountInfo, java.lang.Long)
+   */
+  @Override
+  public void createAndScheduleMissingScss(
+      SourceChangeEventKey scs,
+      String commitSha1TransactionEnd,
+      AccountInfo submitter,
+      Long submittedAt)
+      throws EventParsingException {
+    SourceChangeEventKey currentScs = scs;
+    SourceChangeEventKey scc = scs.copy(SCC);
+    try {
+      try (ScsWalker scsFinder =
+          walkerFactory.scsWalker(scs, commitSha1TransactionEnd, submitter, submittedAt)) {
+
+        if (eventHub.getExistingId(scc).isEmpty()) {
+          /* One or several SCC events are missing, create them first */
+          try (UnprocessedCommitsWalker sccFinder = scsFinder.sccWalker()) {
+            createAndScheduleMissingSccs(scc, sccFinder);
+          }
+        }
+
+        if (!scsFinder.hasNext()) {
+          logger.atInfo().log("All events were already published for %s", scs);
+        } else {
+          logger.atFine().log("Start publishing events for: %s", scs);
+        }
+        while (scsFinder.hasNext()) {
+          EventCreate create = scsFinder.next();
+          currentScs = create.key;
+          Optional<UUID> sccId = eventHub.getExistingId(create.key.copy(SCC));
+          if (sccId.isEmpty()) {
+            throw new EiffelEventIdLookupException(
+                "Unable to find SCC event id: %s", create.key.copy(SCC));
+          }
+          List<UUID> scsParentEventIds = Lists.newArrayList();
+          for (RevCommit parent : create.commit.getParents()) {
+            Optional<UUID> parentUuid = eventHub.getExistingId(create.key.copy(parent.getName()));
+            if (parentUuid.isPresent()) {
+              scsParentEventIds.add(parentUuid.get());
+            } else {
+              exceptionForMissingParent(create, parent);
+            }
+          }
+          pushToHub(
+              mapper.toScs(
+                  create.commit,
+                  create.key.repo(),
+                  create.key.branch(),
+                  create.submitter,
+                  create.submittedAt,
+                  scsParentEventIds,
+                  sccId.get()));
+        }
+      }
+      logger.atFine().log("Done publishing events for: %s", scs);
+    } catch (IOException
+        | EiffelEventIdLookupException
+        | InterruptedException
+        | ConfigInvalidException
+        | NoSuchEntityException e) {
+      throw new EventParsingException(e, "Failed to create Eiffel event(s) for %s.", currentScs);
+    }
+  }
+
+  /* (non-Javadoc)
+   * @see com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser#createAndScheduleArtc(java.lang.String, java.lang.String, java.lang.Long, boolean)
+   */
+  @Override
+  public void createAndScheduleArtc(
+      String repoName, String tagName, Long creationTime, boolean force)
+      throws EventParsingException {
+    try {
+      CompositionDefinedEventKey cd =
+          CompositionDefinedEventKey.create(mapper.tagCompositionName(repoName), tagName);
+      Optional<UUID> oldCdId = eventHub.getExistingId(cd);
+      if (oldCdId.isEmpty() || force) {
+        createAndScheduleCd(repoName, tagName, creationTime, force);
+        Optional<UUID> cdId = eventHub.getExistingId(cd);
+        if (cdId.isPresent() && !cdId.equals(oldCdId)) {
+          pushToHub(mapper.toArtc(repoName, tagName, creationTime, cdId.get()), force);
+          if (oldCdId.isPresent()) {
+            logger.atInfo().log(
+                "Event Artc has been forcibly created for: %s, %s", repoName, tagName);
+          } else {
+            logger.atFine().log("Event Artc has been created for: %s, %s", repoName, tagName);
+          }
+        }
+      } else {
+        /* Artc event has already been created */
+        logger.atWarning().log(
+            "Event Artc has already been created for: %s, %s", repoName, tagName);
+      }
+    } catch (EiffelEventIdLookupException | InterruptedException e) {
+      throw new EventParsingException(
+          e, "Event creation failed for: %s, %s to Artc", repoName, tagName);
+    }
+  }
+
+  private void createAndScheduleCd(
+      String projectName, String tagName, Long creationTime, boolean force)
+      throws EventParsingException {
+    Optional<UUID> scsId = Optional.empty();
+    List<Ref> refs = null;
+
+    try {
+      EventsFilter filter = eventsFilter.get();
+      ObjectId objectId = peelTag(projectName, tagName, filter.blockLightWeightTags());
+      if (objectId == null) {
+        return;
+      }
+      String commitId = objectId.getName();
+
+      /* Check if an event for commit~master has been created. */
+      SourceChangeEventKey scs =
+          SourceChangeEventKey.scsKey(projectName, RefNames.REFS_HEADS + "master", commitId);
+      scsId = eventHub.getExistingId(scs);
+
+      if (scsId.isEmpty()) {
+        /* No event created for commit~master. Check if event is created for any
+        of the other branches. */
+        Retryer<Optional<UUID>> retryer =
+            RetryerBuilder.<Optional<UUID>>newBuilder()
+                .retryIfResult(Optional::isEmpty)
+                .withWaitStrategy(WaitStrategies.fixedWait(10, TimeUnit.SECONDS))
+                .withStopStrategy(StopStrategies.stopAfterAttempt(2))
+                .build();
+        try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
+          refs =
+              repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_HEADS).stream()
+                  .collect(Collectors.toList());
+        } catch (IOException e) {
+          throw new EventParsingException(
+              e, "Unable to get branches for: %s:%s", projectName, tagName);
+        }
+        try {
+          List<String> branches =
+              refs.stream()
+                  .map(Ref::getName)
+                  .filter(branch -> !branch.equals(RefNames.REFS_HEADS + "master"))
+                  .collect(Collectors.toList());
+          scsId = retryer.call(() -> findSourceChangeEventKey(projectName, commitId, branches));
+        } catch (RetryException | ExecutionException e) {
+          logger.atWarning().withCause(e).log(
+              "Failed to find SCS for %s in %s when trying to create CD for tag %s",
+              commitId, projectName, tagName);
+        }
+      }
+
+      if (scsId.isEmpty()) {
+        /* No event has been created for the commit. Find any non-blocked branch that
+        commit is merged into and create SCS events for that branch. */
+        List<Ref> branches;
+        try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
+          RevWalk rw = new RevWalk(repo);
+          refs =
+              refs.stream()
+                  .filter(ref -> !filter.refIsBlocked(ref.getName()))
+                  .collect(Collectors.toList());
+          branches = RevWalkUtils.findBranchesReachableFrom(rw.parseCommit(objectId), rw, refs);
+        } catch (IOException e) {
+          throw new EventParsingException(
+              e, "Unable to get reachable branches for: %s:%s", projectName, tagName);
+        }
+        if (branches.isEmpty()) {
+          throw new EventParsingException(
+              "Could not find any unblocked branch for SCS with: %s in %s so CD could not be created for tag %s",
+              commitId, projectName, tagName);
+        }
+        String branch = branches.get(0).getName();
+        scs = SourceChangeEventKey.scsKey(projectName, branch, commitId);
+        createAndScheduleMissingScss(scs, null, null, null);
+        scsId = eventHub.getExistingId(scs);
+      }
+
+      if (scsId.isEmpty()) {
+        throw new EventParsingException(
+            "Could not find or create SCS for %s in %s so CD could not be created for tag %s",
+            commitId, projectName, tagName);
+      }
+      pushToHub(mapper.toCd(projectName, tagName, creationTime, scsId.get()), force);
+    } catch (EiffelEventIdLookupException | InterruptedException e) {
+      throw new EventParsingException(
+          e,
+          "Event creation failed for: %s",
+          CompositionDefinedEventKey.create(mapper.tagCompositionName(projectName), tagName));
+    }
+  }
+
+  private ObjectId peelTag(String projectName, String tagName, boolean blockLightWeightTags)
+      throws EventParsingException {
+    try (Repository repo = repoManager.openRepository(Project.nameKey(projectName))) {
+      Ref tagRef = repo.getRefDatabase().exactRef(Constants.R_TAGS + tagName);
+      if (tagRef != null) {
+        ObjectId peeled = repo.getRefDatabase().peel(tagRef).getPeeledObjectId();
+        if (peeled != null) return peeled;
+        if (!blockLightWeightTags) return tagRef.getObjectId();
+        logger.atInfo().log("Creation of CD is blocked for lightweight tags");
+        return null;
+      }
+      throw new EventParsingException("Cannot find tag: %s:%s", projectName, tagName);
+    } catch (IOException e) {
+      throw new EventParsingException(e, "Unable to peel tag: %s:%s", projectName, tagName);
+    }
+  }
+
+  private Optional<UUID> findSourceChangeEventKey(
+      String projectName, String commitId, List<String> branches)
+      throws EiffelEventIdLookupException {
+    return eventHub.getScsForCommit(projectName, commitId, branches);
+  }
+
+  private void pushToHub(EiffelEvent toPush) throws InterruptedException {
+    pushToHub(toPush, false);
+  }
+
+  private void pushToHub(EiffelEvent toPush, boolean force) throws InterruptedException {
+    int failureCount = 0;
+    EventKey key = EventKey.fromEvent(toPush);
+    while (true) {
+      try {
+        eventHub.push(toPush, force);
+        logger.atFine().log("Successfully pushed %s to EventHub", key);
+        return;
+      } catch (InterruptedException e) {
+        if (!eventHub.isOpen()) {
+          logger.atInfo().log("EventHub is closed, aborting.");
+          throw e;
+        }
+        failureCount++;
+        if (failureCount < NBR_RETRIES) {
+          logger.atWarning().withCause(e).log(
+              "Interrupted while pushing %s to EventHub, attempt %d/%d",
+              key, failureCount, NBR_RETRIES);
+        } else {
+          throw e;
+        }
+      }
+    }
+  }
+
+  @VisibleForTesting
+  void createAndScheduleMissingSccs(SourceChangeEventKey scc)
+      throws MissingObjectException, IncorrectObjectTypeException, IOException,
+          EiffelEventIdLookupException, RepositoryNotFoundException, NoSuchEntityException,
+          ConfigInvalidException, InterruptedException {
+    logger.atFine().log("Start publishing events for: %s", scc);
+    try (UnprocessedCommitsWalker commitFinder = walkerFactory.sccWalker(scc)) {
+      createAndScheduleMissingSccs(scc, commitFinder);
+    }
+    logger.atFine().log("Done publishing events for: %s", scc);
+  }
+
+  /* Callers are responsible for closing commitFinder. */
+  private void createAndScheduleMissingSccs(
+      SourceChangeEventKey scc, UnprocessedCommitsWalker commitFinder)
+      throws MissingObjectException, EiffelEventIdLookupException, IOException,
+          NoSuchEntityException, ConfigInvalidException, InterruptedException {
+    if (!commitFinder.hasNext()) {
+      logger.atInfo().log("All events were already published for %s", scc);
+    } else {
+      logger.atFine().log("Start publishing events for: %s", scc);
+    }
+    while (commitFinder.hasNext()) {
+      EventCreate job = commitFinder.next();
+      logger.atFine().log("Processing event-creation for: %s", job.key);
+      List<UUID> parentIds = Lists.newArrayList();
+      for (RevCommit parent : job.commit.getParents()) {
+        SourceChangeEventKey parentKey = scc.copy(parent.getName());
+        Optional<UUID> parentId = eventHub.getExistingId(parentKey);
+        if (parentId.isPresent()) {
+          parentIds.add(parentId.get());
+        } else {
+          exceptionForMissingParent(job, parent);
+        }
+      }
+      try {
+        pushToHub(mapper.toScc(job.commit, job.key.repo(), job.key.branch(), parentIds));
+      } catch (InterruptedException e) {
+        logger.atSevere().log("Interrupted while pushing %s to EventHub.", job.key);
+        throw e;
+      }
+    }
+  }
+
+  private ObjectId getTipOf(String repoName, String branch) {
+    try (Repository repo = repoManager.openRepository(Project.nameKey(repoName))) {
+      Ref branchRef = repo.exactRef(branch);
+      if (branchRef == null) {
+        logger.atWarning().log("Could not find ref: %s in project: %s", branch, repoName);
+        return null;
+      }
+      return branchRef.getTarget().getObjectId();
+    } catch (IOException ioe) {
+      logger.atSevere().withCause(ioe).log("Unable to identify tip of (%s:%s).", repoName, branch);
+      return null;
+    }
+  }
+
+  private void exceptionForMissingParent(EventCreate create, RevCommit parent)
+      throws NoSuchEntityException {
+    throw new NoSuchEntityException(
+        String.format(
+            "Unable to lookup parent (%s) event UUID for %s even though it should exist.",
+            parent.abbreviate(7).name(), create.key));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueue.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueue.java
index 1fd7ec5..01d5233 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueue.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueue.java
@@ -18,10 +18,11 @@
 import static com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType.SCC;
 import static com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType.SCS;
 
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.Project.NameKey;
-import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.gerrit.server.git.ProjectRunnable;
@@ -29,7 +30,9 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey;
-import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ScheduledFuture;
 
@@ -38,188 +41,186 @@
 
   private volatile EiffelEventParsingExecutor pool;
   private final EiffelEventParser eventParser;
+  private final ParsingQueuePersistence persistence;
   private final ConcurrentHashMap<EventParsingWorker, ScheduledFuture<?>> pending =
       new ConcurrentHashMap<>();
+  private final Set<ParsingQueueTask> failedTasks = new HashSet<>();
+  private final Object failedTasksLock = new Object();
 
   @Inject
-  public EiffelEventParsingQueue(EiffelEventParsingExecutor pool, EiffelEventParser eventParser) {
+  public EiffelEventParsingQueue(
+      EiffelEventParsingExecutor pool,
+      EiffelEventParser eventParser,
+      ParsingQueuePersistence persistence) {
     this.pool = pool;
     this.eventParser = eventParser;
+    this.persistence = persistence;
   }
 
   public void scheduleSccCreation(RevisionCreatedListener.Event event) {
-    schedule(
-        new EventParsingWorker(
-            SCC,
-            event.getChange().project,
-            event.getChange().branch,
-            event.getRevision().commit.commit) {
-
-          @Override
-          public void doRun() {
-            try {
-              eventParser.createAndScheduleSccFromEvent(event);
-            } catch (Exception e) {
-              logger.atSevere().withCause(e).log(
-                  "Failed to create SCC from event for %s:%s:%s.",
-                  event.getChange().project, event.getChange().branch, event.getRevision().commit);
-            }
-          }
-        });
+    schedule(ParsingQueueTask.sccBuilder(event));
   }
 
   /**
-   * Schedule SCC creation from a branch ref. This is the special case where the ref that the
-   * commits, that the events that are supposed to be created from, are reachable from is the same
-   * as the target Branch.
+   * Schedules SCC creation for all commits reachable from branchRef.
    *
-   * @param repoName
-   * @param branch
+   * @param repoName - the repository containing the commits to create events from.
+   * @param branchRef - create events for commits reachable from tip of branchRef.
    */
-  public void scheduleSccCreation(String repoName, String branch) {
-    scheduleSccCreation(repoName, branch, branch);
+  public void scheduleSccCreation(String repoName, String branchRef) {
+    schedule(ParsingQueueTask.builder(SCC, repoName, branchRef));
   }
 
   /**
-   * Schedule SCC creation from a ref. ref can be a branch or a patch-set ref.
+   * Schedule SCC creation for all commits reachable from commit.
    *
-   * @param repoName
-   * @param ref - schedule event creation for commits reachable from this ref.
-   * @param targetBranch - used to populate data.gitIdentifier.branch.
+   * @param repoName - the repository containing the commits to create events from.
+   * @param branchRef - used to populate data.gitIdentifier.branch.
+   * @param commit - create events from commits reachable from commit.
    */
-  public void scheduleSccCreation(String repoName, String ref, String targetBranch) {
-    schedule(
-        new EventParsingWorker(SCC, repoName, targetBranch, null) {
-
-          @Override
-          public void doRun() {
-            try {
-              eventParser.createAndScheduleSccFromRef(repoName, ref, targetBranch);
-            } catch (Exception e) {
-              logger.atSevere().withCause(e).log(
-                  "Failed to create SCC for %s:%s:%s.", repoName, ref, targetBranch);
-            }
-          }
-        });
+  public void scheduleSccCreation(String repoName, String branchRef, String commit) {
+    schedule(ParsingQueueTask.builder(SCC, repoName, branchRef).commit(commit));
   }
 
   public void scheduleScsCreation(GitReferenceUpdatedListener.Event event) {
     schedule(
-        new EventParsingWorker(
-            SCS, event.getProjectName(), event.getRefName(), event.getNewObjectId()) {
-
-          @Override
-          public void doRun() {
-            try {
-              eventParser.createAndScheduleMissingScss(
-                  SourceChangeEventKey.scsKey(
-                      event.getProjectName(), event.getRefName(), event.getNewObjectId()),
-                  event.getOldObjectId(),
-                  event.getUpdater(),
-                  TimeUtil.nowMs());
-            } catch (Exception e) {
-              logger.atSevere().withCause(e).log(
-                  "Failed to create SCS from event for %s:%s:%s.",
-                  event.getProjectName(), event.getRefName(), event.getNewObjectId());
-            }
-          }
-        });
+        ParsingQueueTask.builder(SCS, event.getProjectName(), event.getRefName())
+            .commit(event.getNewObjectId())
+            .updater(event.getUpdater())
+            .updateTime(TimeUtil.nowMs())
+            .previousTip(event.getOldObjectId()));
   }
 
-  public void scheduleScsCreation(String repoName, String branch) {
-    schedule(
-        new EventParsingWorker(SCS, repoName, branch, null) {
-
-          @Override
-          public void doRun() {
-            try {
-              eventParser.createAndScheduleMissingScssFromBranch(repoName, branch);
-            } catch (Exception e) {
-              logger.atSevere().withCause(e).log(
-                  "Failed to create SCS for %s:%s", repoName, branch);
-            }
-          }
-        });
+  public void scheduleScsCreation(String repoName, String branchRef) {
+    schedule(ParsingQueueTask.builder(SCS, repoName, branchRef));
   }
 
   public void scheduleArtcCreation(GitReferenceUpdatedListener.Event event) {
-    scheduleArtcCreation(
-        event.getProjectName(),
-        event.getRefName().substring(RefNames.REFS_TAGS.length()),
-        TimeUtil.nowMs(),
-        false);
+    schedule(
+        ParsingQueueTask.builder(ARTC, event.getProjectName(), event.getRefName())
+            .updateTime(TimeUtil.nowMs())
+            .force(false));
   }
 
   public void scheduleArtcCreation(TagResource resource, boolean force) {
-    String tagRef = resource.getRef();
-    scheduleArtcCreation(
-        resource.getName(),
-        tagRef.startsWith(RefNames.REFS_TAGS)
-            ? tagRef.substring(RefNames.REFS_TAGS.length())
-            : tagRef,
-        resource.getTagInfo().created.getTime(),
-        force);
-  }
-
-  public void scheduleArtcCreation(
-      String projectName, String tagName, Long creationTime, boolean force) {
     schedule(
-        new EventParsingWorker(ARTC, projectName, tagName) {
-
-          @Override
-          public void doRun() {
-            try {
-              eventParser.createAndScheduleArtc(projectName, tagName, creationTime, force);
-            } catch (Exception e) {
-              logger.atSevere().withCause(e).log(
-                  "Failed to create ARTC for %s:%s", projectName, tagName);
-            }
-          }
-        });
+        ParsingQueueTask.builder(ARTC, resource.getName(), resource.getRef())
+            .updateTime(resource.getTagInfo().created.getTime())
+            .force(force));
   }
 
   public void shutDown() {
-    for (ScheduledFuture<?> f : pending.values()) {
-      f.cancel(true);
+    Set<ParsingQueueTask> tasks = Sets.newHashSet();
+    for (Map.Entry<EventParsingWorker, ScheduledFuture<?>> e : pending.entrySet()) {
+      e.getValue().cancel(true);
+      tasks.add(e.getKey().task);
     }
+    persistence.persistQueue(tasks);
+    persistence.persistFailed(failedTasks);
     pool.shutDown();
   }
 
-  private void schedule(EventParsingWorker worker) {
+  public void init() {
+    for (ParsingQueueTask task : persistence.getFailedTasks()) {
+      schedule(task);
+    }
+    for (ParsingQueueTask task : persistence.getPersistedQueue()) {
+      schedule(task);
+    }
+  }
+
+  /**
+   * Reschedules all failed tasks.
+   *
+   * @return the number of failed tasks that were rescheduled.
+   */
+  public int rescheduleFailed() {
+    int failedCnt = 0;
+    synchronized (failedTasksLock) {
+      failedCnt = failedTasks.size();
+      for (ParsingQueueTask task : failedTasks) {
+        schedule(task);
+      }
+      failedTasks.clear();
+    }
+    return failedCnt;
+  }
+
+  protected void markAsCompleted(EventParsingWorker worker) {
+    pending.remove(worker);
+    if (!worker.successful) {
+      synchronized (failedTasksLock) {
+        failedTasks.add(worker.task);
+      }
+    }
+  }
+
+  @VisibleForTesting
+  void schedule(ParsingQueueTask.Builder taskBuilder) {
+    schedule(taskBuilder.build());
+  }
+
+  private void schedule(ParsingQueueTask task) {
+    EventParsingWorker worker = new EventParsingWorker(task);
     ScheduledFuture<?> future = pool.schedule(worker);
     if (future != null) {
       pending.put(worker, future);
     }
   }
 
-  protected void markAsCompleted(EventParsingWorker worker) {
-    pending.remove(worker);
-  }
+  class EventParsingWorker implements ProjectRunnable {
+    private final ParsingQueueTask task;
+    boolean running;
+    boolean successful = true;
 
-  abstract class EventParsingWorker implements ProjectRunnable {
-    private final String repoName;
-    private final String refName;
-    private final String commitId;
-    private final EiffelEventType type;
-    private boolean running;
-
-    public EventParsingWorker(EiffelEventType type, String repoName, String refName) {
-      this(type, repoName, refName, null);
+    public EventParsingWorker(ParsingQueueTask task) {
+      this.task = task;
     }
 
-    public EventParsingWorker(
-        EiffelEventType type, String repoName, String refName, String commitId) {
-      this.type = type;
-      this.repoName = repoName;
-      this.refName =
-          refName.startsWith(RefNames.REFS_HEADS)
-              ? refName.substring(RefNames.REFS_HEADS.length())
-              : refName;
-      this.commitId = commitId;
+    public void doRun() {
+      String append = null;
+      try {
+        switch (task.type) {
+          case SCC:
+            if (task.patchsetCreatedEvent != null) {
+              append = ", from event, ";
+              eventParser.createAndScheduleSccFromEvent(task.patchsetCreatedEvent);
+            } else if (task.commitId != null) {
+              eventParser.createAndScheduleSccFromCommit(
+                  task.repoName, task.branchRefOrTag, task.commitId);
+            } else {
+              eventParser.createAndScheduleSccFromBranch(task.repoName, task.branchRefOrTag);
+            }
+            break;
+          case SCS:
+            if (task.commitId != null) {
+              append = ", from submit, ";
+              eventParser.createAndScheduleMissingScss(
+                  SourceChangeEventKey.scsKey(task.repoName, task.branchRefOrTag, task.commitId),
+                  task.previousTip,
+                  task.updater,
+                  task.updateTime);
+            } else {
+              eventParser.createAndScheduleMissingScssFromBranch(
+                  task.repoName, task.branchRefOrTag);
+            }
+            break;
+          case ARTC:
+            eventParser.createAndScheduleArtc(
+                task.repoName, task.branchRefOrTag, task.updateTime, task.force);
+            break;
+          case CD:
+          default:
+            logger.atWarning().log("Cannot schedule events for type: %s.", task.type);
+        }
+        successful = true;
+      } catch (EventParsingException e) {
+        logger.atSevere().withCause(e).log(
+            "Failed to create events%sfrom %s", append != null ? append : " ", task);
+        successful = false;
+      }
     }
 
-    public abstract void doRun();
-
     @Override
     public void run() {
       running = true;
@@ -230,7 +231,7 @@
 
     @Override
     public NameKey getProjectNameKey() {
-      return Project.nameKey(repoName);
+      return Project.nameKey(task.repoName);
     }
 
     @Override
@@ -245,14 +246,7 @@
 
     @Override
     public String toString() {
-      if (commitId != null) {
-        return String.format(
-            "Parsing: %s[%s, %s, %s]%s",
-            type.name(), repoName, refName, commitId, running ? " (running)" : "");
-      }
-      return String.format(
-          "Parsing from branch: %s[%s, %s]%s",
-          type.name(), repoName, refName, running ? " (running)" : "");
+      return String.format("Parsing: %s%s", task, running ? " (running)" : "");
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EventParsingException.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EventParsingException.java
new file mode 100644
index 0000000..7503b63
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EventParsingException.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+/** Thrown when EventParsing fails. */
+public class EventParsingException extends Exception {
+
+  /** */
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * @param cause - The cause of this exception.
+   * @param formatString - as if passed to String.format(formatString, objects)
+   * @param objects - as if passed to String.format(formatString, objects)
+   */
+  public EventParsingException(Throwable cause, String formatString, Object... objects) {
+    super(String.format(formatString, objects), cause);
+  }
+
+  /**
+   * @param formatString - as if passed to String.format(formatString, objects)
+   * @param objects - as if passed to String.format(formatString, objects)
+   */
+  public EventParsingException(String formatString, Object... objects) {
+    super(String.format(formatString, objects));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueuePersistence.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueuePersistence.java
new file mode 100644
index 0000000..25874f0
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueuePersistence.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.common.reflect.TypeToken;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gson.Gson;
+import com.google.gson.JsonIOException;
+import com.google.gson.JsonSyntaxException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.Set;
+
+public class ParsingQueuePersistence {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private static final Gson GSON = new Gson();
+  private static final String PERSISTED_QUEUE_FILE_NAME = "persisted-queue.json";
+  private static final String PERSISTED_FAILED_FILE_NAME = "peristed-failed.json";
+
+  private static void persistTo(Set<ParsingQueueTask> tasks, Path dir, String fileName) {
+    Path file = dir.resolve(fileName);
+    try {
+      if (Files.isRegularFile(file)) {
+        Files.delete(file);
+      }
+      if (tasks == null || tasks.isEmpty()) {
+        return;
+      }
+      if (!Files.isDirectory(dir)) {
+        Files.createDirectory(dir);
+      }
+      Files.write(file, GSON.toJson(tasks).getBytes(UTF_8));
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Failed to persist tasks to : %s", file);
+    }
+  }
+
+  private static Set<ParsingQueueTask> getTasksFrom(Path file) {
+    Set<ParsingQueueTask> persistedTasks = null;
+    try {
+      if (Files.isRegularFile(file)) {
+        persistedTasks =
+            GSON.fromJson(
+                Files.newBufferedReader(file, UTF_8),
+                new TypeToken<Set<ParsingQueueTask>>() {
+
+                  private static final long serialVersionUID = 1L;
+                }.getType());
+      }
+    } catch (JsonIOException | JsonSyntaxException | IOException e) {
+      logger.atSevere().withCause(e).log("Failed to get tasks from %s.", file);
+    }
+    return persistedTasks != null ? persistedTasks : Set.of();
+  }
+
+  private final Path pluginDataDir;
+
+  @Inject
+  public ParsingQueuePersistence(SitePaths sitePaths, @PluginName String pluginName) {
+    this.pluginDataDir = sitePaths.data_dir.resolve(pluginName);
+  }
+
+  public void persistQueue(Set<ParsingQueueTask> tasks) {
+    persistTo(tasks, pluginDataDir, PERSISTED_QUEUE_FILE_NAME);
+  }
+
+  public void persistFailed(Set<ParsingQueueTask> failedTasks) {
+    persistTo(failedTasks, pluginDataDir, PERSISTED_FAILED_FILE_NAME);
+  }
+
+  public Collection<ParsingQueueTask> getPersistedQueue() {
+    return getTasksFrom(pluginDataDir.resolve(PERSISTED_QUEUE_FILE_NAME));
+  }
+
+  public Collection<ParsingQueueTask> getFailedTasks() {
+    return getTasksFrom(pluginDataDir.resolve(PERSISTED_FAILED_FILE_NAME));
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueueTask.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueueTask.java
new file mode 100644
index 0000000..245c4b3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueueTask.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType;
+import java.util.Objects;
+
+/**
+ * DTO that contains all necessary data to describe and reschedule a task in the {@link
+ * EiffelEventParsingQueue}.
+ */
+public class ParsingQueueTask {
+  static Builder builder(EiffelEventType type, String repoName, String branchRefOrTag) {
+    return new Builder(type, repoName, branchRefOrTag);
+  }
+
+  /**
+   * The event contains all information necessary to create a SCC without querying the index.
+   * Setting the event also automatically sets repoName, branchRefOrTag, commitId from the event and
+   * type to SCC and ignores all other fields.
+   */
+  static Builder sccBuilder(RevisionCreatedListener.Event event) {
+    return new Builder(event);
+  }
+
+  public static class Builder {
+    private EiffelEventType _type;
+    private String _repoName;
+    private String _branchRefOrTag;
+    private String _commitId = null;
+    private AccountInfo _updater = null;
+    private Long _updateTime = null;
+    private Boolean _force = null;
+    private String _previousTip = null;
+    private RevisionCreatedListener.Event _patchsetCreatedEvent;
+
+    private Builder(EiffelEventType type, String repoName, String branchRefOrTag) {
+      this._type = type;
+      this._repoName = repoName;
+      this._branchRefOrTag = branchRefOrTag;
+    }
+
+    private Builder(RevisionCreatedListener.Event event) {
+      this._patchsetCreatedEvent = event;
+    }
+
+    public Builder commit(String commitId) {
+      this._commitId = commitId;
+      return this;
+    }
+
+    public Builder updater(AccountInfo updater) {
+      this._updater = updater;
+      return this;
+    }
+
+    public Builder updateTime(Long updateTime) {
+      this._updateTime = updateTime;
+      return this;
+    }
+
+    public Builder force(boolean force) {
+      this._force = force;
+      return this;
+    }
+
+    public Builder previousTip(String previousTip) {
+      this._previousTip = previousTip;
+      return this;
+    }
+
+    public ParsingQueueTask build() {
+      if (this._patchsetCreatedEvent != null) {
+        return new ParsingQueueTask(_patchsetCreatedEvent);
+      }
+      return new ParsingQueueTask(
+          this._type,
+          this._repoName,
+          this._branchRefOrTag,
+          this._commitId,
+          this._updater,
+          this._updateTime,
+          this._previousTip,
+          this._force);
+    }
+  }
+
+  final String repoName;
+  final String branchRefOrTag;
+  final String commitId;
+  final EiffelEventType type;
+  final AccountInfo updater;
+  final Long updateTime;
+  final Boolean force;
+  final String previousTip;
+  final RevisionCreatedListener.Event patchsetCreatedEvent;
+
+  private ParsingQueueTask(
+      EiffelEventType type,
+      String repoName,
+      String branchRefOrTag,
+      String commitId,
+      AccountInfo updater,
+      Long updateTime,
+      String previousTip,
+      Boolean force) {
+    this.type = type;
+    this.repoName = repoName;
+    this.branchRefOrTag =
+        branchRefOrTag.startsWith(RefNames.REFS_TAGS)
+            ? branchRefOrTag.substring(RefNames.REFS_TAGS.length())
+            : branchRefOrTag;
+    this.commitId = commitId;
+    this.updater = updater;
+    this.updateTime = updateTime;
+    this.previousTip = previousTip;
+    this.force = force;
+    this.patchsetCreatedEvent = null;
+  }
+
+  private ParsingQueueTask(RevisionCreatedListener.Event patchsetCreatedEvent) {
+    this.type = EiffelEventType.SCC;
+    this.repoName = patchsetCreatedEvent.getChange().project;
+    this.branchRefOrTag = patchsetCreatedEvent.getChange().branch;
+    this.commitId = patchsetCreatedEvent.getRevision().commit.commit;
+    this.updater = null;
+    this.updateTime = null;
+    this.previousTip = null;
+    this.force = null;
+    this.patchsetCreatedEvent = patchsetCreatedEvent;
+  }
+
+  @Override
+  public String toString() {
+    String target =
+        branchRefOrTag.startsWith(RefNames.REFS_HEADS)
+            ? branchRefOrTag.substring(RefNames.REFS_HEADS.length())
+            : branchRefOrTag;
+    if (commitId != null) {
+      return String.format("%s[%s, %s, %s]", type.name(), repoName, target, commitId);
+    }
+    return String.format("%s[%s, %s]", type.name(), repoName, target);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof ParsingQueueTask) {
+      ParsingQueueTask that = (ParsingQueueTask) o;
+      return Objects.equals(this.type, that.type)
+          && Objects.equals(this.repoName, that.repoName)
+          && Objects.equals(this.commitId, that.commitId)
+          && Objects.equals(this.updater, that.updater)
+          && Objects.equals(this.updateTime, that.updateTime)
+          && Objects.equals(this.previousTip, that.previousTip)
+          && Objects.equals(this.force, that.force);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(type, repoName, commitId, updater, updateTime, previousTip, force);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/rest/CreateSccs.java b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/rest/CreateSccs.java
index 1f5deb6..1f4416e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/rest/CreateSccs.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/eventseiffel/rest/CreateSccs.java
@@ -60,7 +60,7 @@
               .findFirst()
               .map(c -> c.change().getDest().branch());
       if (targetBranch.isPresent()) {
-        queue.scheduleSccCreation(resource.getName(), resource.getRef(), targetBranch.get());
+        queue.scheduleSccCreation(resource.getName(), targetBranch.get(), resource.getRevision());
         return EventCreationResponse.scc(resource, targetBranch.get());
       }
       throw new BadRequestException(
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTest.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTest.java
index 3652427..0433b7a 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTest.java
@@ -29,6 +29,7 @@
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.EventKey;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.api.EventStorageException;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelLinkInfo;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelLinkType;
 import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventFactory;
@@ -117,6 +118,22 @@
         result.getCommit().getName());
   }
 
+  protected void setScsHandled() throws Exception {
+    markAsHandled(eventForHead(EiffelEventType.SCS), getHead(repo(), "HEAD"));
+  }
+
+  protected SourceChangeEventKey eventForHead(EiffelEventType type) throws Exception {
+    return SourceChangeEventKey.create(project.get(), getHead(), getHeadRevision(), type);
+  }
+
+  protected String getHead() throws Exception {
+    return gApi.projects().name(project.get()).head();
+  }
+
+  protected String getHeadRevision() throws Exception {
+    return getHead(repo(), "HEAD").getName();
+  }
+
   protected UUID markAsHandled(EventKey key) throws EventStorageException {
     Optional<UUID> eventId = TestEventStorage.INSTANCE.getEventId(key);
     if (eventId.isPresent()) {
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTestModule.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTestModule.java
index f51402a..345753d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTestModule.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/EiffelEventsTestModule.java
@@ -27,6 +27,8 @@
 import com.googlesource.gerrit.plugins.eventseiffel.config.EventsFilter;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.api.EventStorage;
 import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventFactory;
+import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParser;
+import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParserImpl;
 import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParsingExecutor;
 import com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParsingQueue;
 import org.junit.Ignore;
@@ -46,6 +48,7 @@
     bind(EiffelEventHub.Consumer.class).to(TestEventPublisher.class);
 
     bind(EventsFilter.class).toProvider(TestEventsFilterProvider.class);
+    bind(EiffelEventParser.class).to(EiffelEventParserImpl.class);
     bind(EiffelEventParsingQueue.class);
     bind(EiffelEventFactory.class).toProvider(TestEventFactoryProvider.class);
 
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserIT.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserIT.java
index f578ad3..e11ee3d 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParserIT.java
@@ -14,6 +14,8 @@
 
 package com.googlesource.gerrit.plugins.eventseiffel.parsing;
 
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType.SCC;
 import static com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType.SCS;
 import static org.junit.Assert.assertEquals;
@@ -24,28 +26,12 @@
 import com.google.gerrit.acceptance.config.GlobalPluginConfig;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.inject.AbstractModule;
-import com.google.inject.Scopes;
-import com.google.inject.Singleton;
-import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventHub;
-import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventsTest;
 import com.googlesource.gerrit.plugins.eventseiffel.TestEventHub;
 import com.googlesource.gerrit.plugins.eventseiffel.TestEventStorage;
-import com.googlesource.gerrit.plugins.eventseiffel.cache.EiffelEventIdCacheImpl;
-import com.googlesource.gerrit.plugins.eventseiffel.config.EiffelConfig;
-import com.googlesource.gerrit.plugins.eventseiffel.config.EventIdCacheConfig;
-import com.googlesource.gerrit.plugins.eventseiffel.config.EventMappingConfig;
-import com.googlesource.gerrit.plugins.eventseiffel.config.EventsFilter;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.ArtifactEventKey;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.CompositionDefinedEventKey;
-import com.googlesource.gerrit.plugins.eventseiffel.eiffel.EventKey;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey;
-import com.googlesource.gerrit.plugins.eventseiffel.eiffel.api.EventStorage;
 import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelSourceChangeSubmittedEventInfo;
-import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventFactory;
-import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventMapper;
-import java.net.URLEncoder;
-import java.nio.charset.StandardCharsets;
 import java.time.Instant;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -54,16 +40,14 @@
 
 @TestPlugin(
     name = "events-eiffel",
-    sysModule =
-        "com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParserIT$TestModule")
-public class EiffelEventParserIT extends EiffelEventsTest {
-  private static final Long EPOCH_MILLIS = 978307261000l;
+    sysModule = "com.googlesource.gerrit.plugins.eventseiffel.parsing.ParsingTestModule")
+public class EiffelEventParserIT extends EiffelEventParsingTest {
 
-  private EiffelEventParser eventParser;
+  private EiffelEventParserImpl eventParser;
 
   @Before
   public void setUp() {
-    eventParser = plugin.getSysInjector().getInstance(EiffelEventParser.class);
+    eventParser = plugin.getSysInjector().getInstance(EiffelEventParserImpl.class);
     TestEventHub.EVENTS.clear();
     TestEventsFilterProvider.reset();
   }
@@ -242,12 +226,6 @@
     return ref;
   }
 
-  private void setScsHandled() throws Exception {
-    SourceChangeEventKey scs =
-        SourceChangeEventKey.scsKey(project.get(), getHead(), getHeadRevision());
-    markAsHandled(scs, getHead(repo(), "HEAD"));
-  }
-
   @Test
   public void artcQueued() throws Exception {
     String ref =
@@ -273,9 +251,11 @@
     TestEventsFilterProvider.setBlockedRefPatterns("refs/heads/master");
     String ref =
         createTagRef(getHead(repo(), "HEAD").getName(), true).substring(Constants.R_TAGS.length());
-
-    eventParser.createAndScheduleArtc(project.get(), ref, EPOCH_MILLIS, false);
-    assertEquals(0, TestEventHub.EVENTS.size());
+    EventParsingException thrown =
+        assertThrows(
+            EventParsingException.class,
+            () -> eventParser.createAndScheduleArtc(project.get(), ref, EPOCH_MILLIS, false));
+    assertThat(thrown).hasMessageThat().startsWith("Could not find any unblocked branch for SCS");
   }
 
   @Test
@@ -302,17 +282,20 @@
 
   @Test
   public void artcQueuedNoTagCreated() throws Exception {
-    eventParser.createAndScheduleArtc(project.get(), TAG_NAME, EPOCH_MILLIS, false);
-    assertEquals(0, TestEventHub.EVENTS.size());
+    EventParsingException thrown =
+        assertThrows(
+            EventParsingException.class,
+            () -> eventParser.createAndScheduleArtc(project.get(), TAG_NAME, EPOCH_MILLIS, false));
+    assertThat(thrown).hasMessageThat().startsWith("Cannot find tag");
   }
 
   @Test
-  public void annotatedTagArtcQueuedscsHandled() throws Exception {
+  public void annotatedTagArtcQueuedScsHandled() throws Exception {
     assertArtcQueuedScsHandled(true);
   }
 
   @Test
-  public void lightWeightTagArtcQueuedscsHandled() throws Exception {
+  public void LightweightTagArtcQueuedScsHandled() throws Exception {
     assertArtcQueuedScsHandled(false);
   }
 
@@ -334,7 +317,7 @@
   }
 
   @Test
-  public void artcQueuedcdHandled() throws Exception {
+  public void artcQueuedCdHandled() throws Exception {
     String ref = setCdHandled();
     eventParser.createAndScheduleArtc(project.get(), ref, EPOCH_MILLIS, false);
     assertEquals(0, TestEventHub.EVENTS.size());
@@ -369,57 +352,4 @@
   private void assertNbrQueriesFor(SourceChangeEventKey key, int nbrQueries) {
     assertEquals(nbrQueries, TestEventStorage.INSTANCE.queriesFor(key));
   }
-
-  private String getHead() throws Exception {
-    return gApi.projects().name(project.get()).head();
-  }
-
-  private String getHeadRevision() throws Exception {
-    return getHead(repo(), "HEAD").getName();
-  }
-
-  private void assertCorrectEvent(int order, EventKey expected) {
-    EventKey actual = EventKey.fromEvent(TestEventHub.EVENTS.get(order));
-    assertEquals(expected, actual);
-  }
-
-  protected static String tagCompositionName(String projectName) {
-    return tagCompositionName(projectName, "localhost");
-  }
-
-  protected static String tagCompositionName(String projectName, String namespace) {
-    return "scmtag/git/" + namespace + "/" + URLEncoder.encode(projectName, StandardCharsets.UTF_8);
-  }
-
-  protected static String tagPURL(String projectName, String tagName) {
-    return tagPURL(projectName, tagName, "localhost");
-  }
-
-  protected static String tagPURL(String projectName, String tagName, String namespace) {
-    return String.format(
-        TAG_PURL_TEMPLATE,
-        namespace,
-        projectName,
-        tagName,
-        String.format("ssh://%s/%s", HOST_NAME, projectName),
-        tagName);
-  }
-
-  public static class TestModule extends AbstractModule {
-    @Override
-    protected void configure() {
-      bind(EventStorage.class).toProvider(TestEventStorage.Provider.class).in(Singleton.class);
-      bind(EventIdCacheConfig.class).toProvider(EventIdCacheConfig.Provider.class);
-      bind(EventsFilter.class).toProvider(TestEventsFilterProvider.class);
-      install(EiffelEventIdCacheImpl.module());
-      bind(EiffelEventHub.class).to(TestEventHub.class);
-      bind(EventMappingConfig.class).toProvider(EventMappingConfig.Provider.class);
-      bind(EiffelEventMapper.class);
-      bind(EiffelEventParser.class);
-      bind(EiffelConfig.class).toProvider(EiffelConfig.Provider.class).in(Scopes.SINGLETON);
-      bind(EiffelEventFactory.class)
-          .toProvider(EiffelEventFactory.Provider.class)
-          .in(Scopes.SINGLETON);
-    }
-  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueueIT.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueueIT.java
new file mode 100644
index 0000000..3de49b8
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingQueueIT.java
@@ -0,0 +1,148 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.googlesource.gerrit.plugins.eventseiffel.TestEventHub;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.ArtifactEventKey;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.CompositionDefinedEventKey;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType;
+import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseLocalDisk
+@TestPlugin(
+    name = "events-eiffel",
+    sysModule =
+        "com.googlesource.gerrit.plugins.eventseiffel.parsing.EiffelEventParsingQueueIT$TestModule")
+public class EiffelEventParsingQueueIT extends EiffelEventParsingTest {
+
+  private ParsingQueuePersistence queuePersistence;
+  private EiffelEventParsingQueue parsingQueue;
+
+  @Before
+  public void setUp() throws Exception {
+    queuePersistence = plugin.getSysInjector().getInstance(ParsingQueuePersistence.class);
+    parsingQueue = plugin.getSysInjector().getInstance(EiffelEventParsingQueue.class);
+    TestEventHub.EVENTS.clear();
+  }
+
+  @Test
+  public void persistedScsFromBranchIsScheduledAndPushed() throws Exception {
+    queuePersistence.persistQueue(
+        Set.of(ParsingQueueTask.builder(EiffelEventType.SCS, project.get(), getHead()).build()));
+    parsingQueue.init();
+    assertCorrectEvent(0, eventForHead(EiffelEventType.SCC));
+    assertCorrectEvent(1, eventForHead(EiffelEventType.SCS));
+  }
+
+  @Test
+  public void persistedScsFromCommitIsScheduledAndPushed() throws Exception {
+    setScsHandled();
+    PushOneCommit.Result res = createChange();
+    merge(res);
+    queuePersistence.persistQueue(
+        Set.of(
+            ParsingQueueTask.builder(EiffelEventType.SCS, project.get(), "refs/heads/master")
+                .commit(res.getCommit().name())
+                .build()));
+    parsingQueue.init();
+    assertCorrectEvent(0, toSccKey(res));
+    assertCorrectEvent(1, toScsKey(res));
+  }
+
+  @Test
+  public void persistedSccFromCommitIsScheduledAndPushed() throws Exception {
+    setScsHandled();
+    PushOneCommit.Result res = createChange();
+    queuePersistence.persistQueue(
+        Set.of(
+            ParsingQueueTask.builder(EiffelEventType.SCC, project.get(), "refs/heads/master")
+                .commit(res.getCommit().name())
+                .build()));
+    parsingQueue.init();
+    assertCorrectEvent(0, toSccKey(res));
+  }
+
+  @Test
+  public void persistedSccFromBranchIsScheduledAndPushed() throws Exception {
+    setScsHandled();
+    PushOneCommit.Result res = createChange();
+    merge(res);
+    queuePersistence.persistQueue(
+        Set.of(
+            ParsingQueueTask.builder(EiffelEventType.SCS, project.get(), "refs/heads/master")
+                .build()));
+    parsingQueue.init();
+    assertCorrectEvent(0, toSccKey(res));
+  }
+
+  @Test
+  public void persistedArtcIsScheduledAndPushed() throws Exception {
+    setScsHandled();
+    String tag =
+        createTagRef(getHead(repo(), "HEAD").getName(), true).substring(Constants.R_TAGS.length());
+    ArtifactEventKey artc = ArtifactEventKey.create(tagPURL(project.get(), tag, "localhost"));
+    CompositionDefinedEventKey cd =
+        CompositionDefinedEventKey.create(tagCompositionName(project.get(), "localhost"), tag);
+    queuePersistence.persistQueue(
+        Set.of(
+            ParsingQueueTask.builder(EiffelEventType.ARTC, project.get(), tag)
+                .updateTime(EPOCH_MILLIS)
+                .force(false)
+                .build()));
+    parsingQueue.init();
+    assertEquals(2, TestEventHub.EVENTS.size());
+    assertCorrectEvent(0, cd);
+    assertCorrectEvent(1, artc);
+  }
+
+  @Test
+  public void failedTaskIsRescheduled() throws Exception {
+    setScsHandled();
+    String tagName = "a-tag";
+    ArtifactEventKey artc = ArtifactEventKey.create(tagPURL(project.get(), tagName, "localhost"));
+    CompositionDefinedEventKey cd =
+        CompositionDefinedEventKey.create(tagCompositionName(project.get(), "localhost"), tagName);
+
+    /* Scheduling ArtC creation for a non-existing tag fails. */
+    parsingQueue.schedule(
+        ParsingQueueTask.builder(EiffelEventType.ARTC, project.get(), tagName)
+            .updateTime(EPOCH_MILLIS)
+            .force(false));
+    assertEquals(0, TestEventHub.EVENTS.size());
+
+    /* Creating the tag should mean that rescheduling the failed task should work. */
+    createTagRef(tagName, getHead(repo(), "HEAD").getName(), true);
+    parsingQueue.rescheduleFailed();
+    assertEquals(2, TestEventHub.EVENTS.size());
+    assertCorrectEvent(0, cd);
+    assertCorrectEvent(1, artc);
+  }
+
+  public static class TestModule extends ParsingTestModule {
+
+    @Override
+    protected void configure() {
+      bind(EiffelEventParsingExecutor.class).to(EiffelEventParsingExecutor.Direct.class);
+      super.configure();
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingTest.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingTest.java
new file mode 100644
index 0000000..a606e14
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/EiffelEventParsingTest.java
@@ -0,0 +1,42 @@
+package com.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import static org.junit.Assert.assertEquals;
+
+import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventsTest;
+import com.googlesource.gerrit.plugins.eventseiffel.TestEventHub;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.EventKey;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import org.junit.Ignore;
+
+@Ignore
+public class EiffelEventParsingTest extends EiffelEventsTest {
+  protected static final Long EPOCH_MILLIS = 978307261000l;
+
+  protected static String tagCompositionName(String projectName) {
+    return tagCompositionName(projectName, "localhost");
+  }
+
+  protected static String tagCompositionName(String projectName, String namespace) {
+    return "scmtag/git/" + namespace + "/" + URLEncoder.encode(projectName, StandardCharsets.UTF_8);
+  }
+
+  protected static String tagPURL(String projectName, String tagName) {
+    return tagPURL(projectName, tagName, "localhost");
+  }
+
+  protected static String tagPURL(String projectName, String tagName, String namespace) {
+    return String.format(
+        TAG_PURL_TEMPLATE,
+        namespace,
+        projectName,
+        tagName,
+        String.format("ssh://%s/%s", HOST_NAME, projectName),
+        tagName);
+  }
+
+  protected void assertCorrectEvent(int order, EventKey expected) {
+    EventKey actual = EventKey.fromEvent(TestEventHub.EVENTS.get(order));
+    assertEquals(expected, actual);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueuePersistenceIT.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueuePersistenceIT.java
new file mode 100644
index 0000000..0923634
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueuePersistenceIT.java
@@ -0,0 +1,224 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.UseLocalDisk;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.events.RevisionCreatedListener.Event;
+import com.google.gerrit.server.git.ProjectRunnable;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.SourceChangeEventKey;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.Before;
+import org.junit.Test;
+
+@UseLocalDisk
+@TestPlugin(
+    name = "events-eiffel",
+    sysModule =
+        "com.googlesource.gerrit.plugins.eventseiffel.parsing.ParsingQueuePersistenceIT$TestModule")
+public class ParsingQueuePersistenceIT extends LightweightPluginDaemonTest {
+
+  private ParsingQueuePersistence queuePersistence;
+  private EiffelEventParsingQueue parsingQueue;
+
+  @Before
+  public void setUp() throws Exception {
+    queuePersistence = plugin.getSysInjector().getInstance(ParsingQueuePersistence.class);
+    /* Purge persistence from previous runs. */
+    queuePersistence.persistFailed(Set.of());
+    queuePersistence.persistQueue(Set.of());
+
+    parsingQueue = plugin.getSysInjector().getInstance(EiffelEventParsingQueue.class);
+  }
+
+  @Test
+  public void queueIsPersisted() throws Exception {
+    Set<ParsingQueueTask> original = createTasks(1, 5);
+    queuePersistence.persistQueue(original);
+    assertThat(original).containsExactlyElementsIn(queuePersistence.getPersistedQueue());
+  }
+
+  @Test
+  public void failedTasksArePersisted() throws Exception {
+    Set<ParsingQueueTask> original = createTasks(1, 5);
+    queuePersistence.persistFailed(original);
+    assertThat(original).containsExactlyElementsIn(queuePersistence.getFailedTasks());
+  }
+
+  @Test
+  public void multipleQueuePersistenceRuns() throws Exception {
+    Set<ParsingQueueTask> original = createTasks(1, 6);
+    queuePersistence.persistQueue(original);
+    assertThat(original).containsExactlyElementsIn(queuePersistence.getPersistedQueue());
+
+    original = createTasks(6, 10);
+    queuePersistence.persistQueue(original);
+    assertThat(original).containsExactlyElementsIn(queuePersistence.getPersistedQueue());
+  }
+
+  @Test
+  public void persistedQueuedTasksAreScheduledOnInit() throws Exception {
+    Set<ParsingQueueTask> tasks = createTasks(1, 6);
+    queuePersistence.persistQueue(tasks);
+    parsingQueue.init();
+    assertThat(
+            TestExecutor.scheduledItems().stream()
+                .map(p -> p.getProjectNameKey())
+                .collect(Collectors.toList()))
+        .containsExactlyElementsIn(
+            tasks.stream().map(t -> Project.nameKey(t.repoName)).collect(Collectors.toList()));
+  }
+
+  @Test
+  public void persistedFailedTasksAreScheduledOnInit() throws Exception {
+    Set<ParsingQueueTask> tasks = createTasks(1, 6);
+    queuePersistence.persistFailed(tasks);
+    parsingQueue.init();
+    assertThat(
+            TestExecutor.scheduledItems().stream()
+                .map(p -> p.getProjectNameKey())
+                .collect(Collectors.toList()))
+        .containsExactlyElementsIn(
+            tasks.stream().map(t -> Project.nameKey(t.repoName)).collect(Collectors.toList()));
+  }
+
+  private Set<ParsingQueueTask> createTasks(int start, int end) {
+    return IntStream.range(start, end)
+        .mapToObj(ParsingQueuePersistenceIT::createTask)
+        .collect(Collectors.toSet());
+  }
+
+  public static ParsingQueueTask createTask(int id) {
+    return ParsingQueueTask.builder(EiffelEventType.SCC, "repo" + id, "branch" + id)
+        .commit("commit-sha1" + id)
+        .build();
+  }
+
+  public static class TestExecutor
+      implements EiffelEventParsingExecutor, Provider<EiffelEventParsingExecutor> {
+    private static TestExecutor INSTANCE;
+
+    public static List<ProjectRunnable> scheduledItems() {
+      return INSTANCE.scheduledItems;
+    }
+
+    private final List<ProjectRunnable> scheduledItems = Lists.newArrayList();
+
+    @Override
+    public ScheduledFuture<?> schedule(ProjectRunnable runnable) {
+      scheduledItems.add(runnable);
+      return new ScheduledFuture<ProjectRunnable>() {
+
+        @Override
+        public long getDelay(TimeUnit arg0) {
+          return 0;
+        }
+
+        @Override
+        public int compareTo(Delayed arg0) {
+          return 0;
+        }
+
+        @Override
+        public boolean cancel(boolean arg0) {
+          return false;
+        }
+
+        @Override
+        public ProjectRunnable get() throws InterruptedException, ExecutionException {
+          return null;
+        }
+
+        @Override
+        public ProjectRunnable get(long arg0, TimeUnit arg1)
+            throws InterruptedException, ExecutionException, TimeoutException {
+          return null;
+        }
+
+        @Override
+        public boolean isCancelled() {
+          return false;
+        }
+
+        @Override
+        public boolean isDone() {
+          return false;
+        }
+      };
+    }
+
+    @Override
+    public void shutDown() {}
+
+    @Override
+    public EiffelEventParsingExecutor get() {
+      INSTANCE = new TestExecutor();
+      return INSTANCE;
+    }
+  }
+
+  public static class NoOpEventParser implements EiffelEventParser {
+
+    public NoOpEventParser() {}
+
+    @Override
+    public void createAndScheduleSccFromEvent(Event event) {}
+
+    @Override
+    public void createAndScheduleSccFromBranch(String repoName, String branchRef) {}
+
+    @Override
+    public void createAndScheduleSccFromCommit(String repoName, String branchRef, String commit) {}
+
+    @Override
+    public void createAndScheduleMissingScssFromBranch(String repoName, String branchRef) {}
+
+    @Override
+    public void createAndScheduleMissingScss(
+        SourceChangeEventKey scs,
+        String commitSha1TransactionEnd,
+        AccountInfo submitter,
+        Long submittedAt) {}
+
+    @Override
+    public void createAndScheduleArtc(
+        String repoName, String tagName, Long creationTime, boolean force) {}
+  }
+
+  public static class TestModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ParsingQueuePersistence.class);
+      bind(EiffelEventParser.class).to(NoOpEventParser.class);
+      bind(EiffelEventParsingExecutor.class).toProvider(TestExecutor.class);
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueueTaskTest.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueueTaskTest.java
new file mode 100644
index 0000000..6c85763
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingQueueTaskTest.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2022 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.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gson.Gson;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.dto.EiffelEventType;
+import org.junit.Test;
+
+public class ParsingQueueTaskTest {
+
+  @Test
+  public void testParsingQueueTaskSerializeable() {
+    AccountInfo accountInfo = new AccountInfo(1);
+    ParsingQueueTask task =
+        ParsingQueueTask.builder(EiffelEventType.SCC, "repo", "branch")
+            .commit("commit-sha1")
+            .updater(accountInfo)
+            .updateTime(0L)
+            .previousTip("previous")
+            .force(true)
+            .build();
+    String json = new Gson().toJson(task);
+    String expected =
+        "{\"repoName\":\"repo\",\"branchRefOrTag\":\"branch\",\"commitId\":\"commit-sha1\",\"type\":\"EiffelSourceChangeCreatedEvent\",\"updater\":{\"_accountId\":1},\"updateTime\":0,\"force\":true,\"previousTip\":\"previous\"}";
+    assertEquals(json, expected);
+  }
+
+  @Test
+  public void testParsingQueueTaskDeserializeable() {
+    String json =
+        "{\"repoName\":\"repo\",\"branchRefOrTag\":\"branch\",\"commitId\":\"commit-sha1\",\"type\":\"EiffelSourceChangeCreatedEvent\",\"updater\":{\"_accountId\":1},\"updateTime\":0,\"force\":true,\"previousTip\":\"previous\"}";
+    ParsingQueueTask deSerialized = new Gson().fromJson(json, ParsingQueueTask.class);
+    assertEquals(deSerialized.repoName, "repo");
+    assertEquals(deSerialized.branchRefOrTag, "branch");
+    assertEquals(deSerialized.commitId, "commit-sha1");
+    assertEquals(deSerialized.type, EiffelEventType.SCC);
+    assertEquals(deSerialized.updater._accountId, Integer.valueOf(1));
+    assertEquals(deSerialized.updateTime, Long.valueOf(0));
+    assertEquals(deSerialized.force, Boolean.TRUE);
+    assertEquals(deSerialized.previousTip, "previous");
+  }
+
+  @Test
+  public void testEqualsAndHashCode() {
+    ParsingQueueTask dis = ParsingQueueTask.builder(EiffelEventType.SCC, "repo", "branch").build();
+    ParsingQueueTask dat = ParsingQueueTask.builder(EiffelEventType.SCC, "repo", "branch").build();
+    assertEquals(dis, dat);
+    assertEquals(dis.hashCode(), dat.hashCode());
+    ParsingQueueTask theOther =
+        ParsingQueueTask.builder(EiffelEventType.SCC, "repo", "branch").commit("commit").build();
+    assertNotEquals(dis, theOther);
+    assertNotEquals(dis.hashCode(), theOther.hashCode());
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingTestModule.java b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingTestModule.java
new file mode 100644
index 0000000..b01640d
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/eventseiffel/parsing/ParsingTestModule.java
@@ -0,0 +1,37 @@
+package com.googlesource.gerrit.plugins.eventseiffel.parsing;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Scopes;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventHub;
+import com.googlesource.gerrit.plugins.eventseiffel.EiffelEventsTest.TestEventsFilterProvider;
+import com.googlesource.gerrit.plugins.eventseiffel.TestEventHub;
+import com.googlesource.gerrit.plugins.eventseiffel.TestEventStorage;
+import com.googlesource.gerrit.plugins.eventseiffel.cache.EiffelEventIdCacheImpl;
+import com.googlesource.gerrit.plugins.eventseiffel.config.EiffelConfig;
+import com.googlesource.gerrit.plugins.eventseiffel.config.EventIdCacheConfig;
+import com.googlesource.gerrit.plugins.eventseiffel.config.EventMappingConfig;
+import com.googlesource.gerrit.plugins.eventseiffel.config.EventsFilter;
+import com.googlesource.gerrit.plugins.eventseiffel.eiffel.api.EventStorage;
+import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventFactory;
+import com.googlesource.gerrit.plugins.eventseiffel.mapping.EiffelEventMapper;
+import org.junit.Ignore;
+
+@Ignore
+public class ParsingTestModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(EventStorage.class).toProvider(TestEventStorage.Provider.class).in(Singleton.class);
+    bind(EventIdCacheConfig.class).toProvider(EventIdCacheConfig.Provider.class);
+    bind(EventsFilter.class).toProvider(TestEventsFilterProvider.class);
+    install(EiffelEventIdCacheImpl.module());
+    bind(EiffelEventHub.class).to(TestEventHub.class);
+    bind(EventMappingConfig.class).toProvider(EventMappingConfig.Provider.class);
+    bind(EiffelEventMapper.class);
+    bind(EiffelEventParser.class).to(EiffelEventParserImpl.class);
+    bind(EiffelConfig.class).toProvider(EiffelConfig.Provider.class).in(Scopes.SINGLETON);
+    bind(EiffelEventFactory.class)
+        .toProvider(EiffelEventFactory.Provider.class)
+        .in(Scopes.SINGLETON);
+  }
+}