sequenceExecute

File

lib/workflows/functions/sequence-execute.ts

Event

sequence/execute

Config

retries: 1, concurrency: { limit: 50 }, cancelOn: [{ event: "sequence/cancel", match: "data.contactSequenceId" }]

One run drives one ContactSequence from enrollment to completion. The Inngest run id is stamped on the enrollment as executionId, and every ContactSequenceStep log produced by the run shares it — so the whole journey is observable both in the database and the Inngest dashboard.

Why one run, not one per step

A step-per-run design would need a scheduler to find due enrollments, a lock to stop two runs advancing the same one, and a way to reconstruct context on every wake-up. A single run instead:

  • loads the template and contact once and indexes them in memory;

  • uses step.sleep for waits, which consumes no concurrency;

  • relies on cancelOn for stopping, so no per-iteration ownership check is needed;

  • gives one executionId that ties the enrollment, its logs and the dashboard run together.

Payload

interface SequenceExecuteEventData {
  contactSequenceId: string
  subscriptionId: string
}

Initialisation

step.run("start-execution") → service.startExecution(contactSequenceId, runId):

  • loads the enrollment with its template steps and contact;

  • refuses to restart a terminal sequence;

  • claims the run by stamping executionId = runId and marking the enrollment active;

  • returns the immutable run context.

Early exits:

Condition Return

Not active, or no context

{ executionId, done: false, reason: "stopped_or_superseded" }

Template has no steps

{ executionId, started: false, reason: "template_has_no_steps" }

The loop

Bounded by MAX_ITERATIONS = 1000 as defence in depth — gate retries are already capped by gateMaxRetries, so this only catches a mis-wired branch cycling forever. Exhausting it runs abandon-runaway.

Each iteration builds buildStepContext(base, stepIndex) in memory — no database read.

No step at this index

complete-no-step-<i> applies { kind: "complete" } and returns { done: true, reason: "no_step_at_index" }.

wait channel

  1. step.sleep("wait-<tag>", "<delayHours><h|m>") when delayHours > 0. The unit comes from delayUnit.

  2. step.run("record-wait-<tag>") → markRunning then recordWait, writing a lightweight log with no response payload so the timeline stays legible.

data_gate channel

On a re-check (gateRetryCount > 0 and gateRetryDelayHours > 0), sleep the retry delay first, then step.run("check-gate-<tag>") → evaluateGate. Marking the step running is folded into the same step.run so it is not a separate durable step.

evaluateGate:

  • resolves {{token}} references in condition values against the contact;

  • requires gateBigqueryView and at least one condition, else throws;

  • with no contact.hubspotContactId there is nothing to scope the query by, so every condition is reported as not met — the gate fails closed;

  • otherwise runs queryGateConditions against BigQuery with the identifier bound as a parameter and the view/field names allowlist-validated.

Other channels

step.run("execute-action-<tag>") → executeAction(ctx, runId), which switches on the channel and returns { status, outcome, eventBody? } alongside the artifact ids it produced. See Action semantics.

Advancing

step.run("finish-<tag>") applies the decision from computeAdvance(step, totalSteps, gateResult, gateRetryCount):

Decision Effect

advance

Move to nextStepIndex, reset gateRetryCount

retry

Re-enter the same gate after delayHours, incrementing gateRetryCount

complete

Mark the enrollment completed

abandon

Mark the enrollment abandoned

A gate with retries configured but no falseBranchStepIndex, having exhausted its retries, abandons — "we waited as long as we agreed to and the condition never became true" is a real outcome, not a step to skip.

Step ids

Ids embed the step index, retry count, channel and a slugified step name — for example execute-action-3-0-email-intro-follow-up. The slug is derived from the memoised context, so ids stay stable across replays while remaining readable in the dashboard.

Cancellation

POST /api/contact-sequences/{id}/stop sends sequence/cancel. Because cancelOn matches on data.contactSequenceId, the run is torn down immediately — including an in-progress step.sleep. This is why the loop body has no ownership re-check.