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.sleepfor waits, which consumes no concurrency; -
relies on
cancelOnfor stopping, so no per-iteration ownership check is needed; -
gives one
executionIdthat ties the enrollment, its logs and the dashboard run together.
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 = runIdand marking the enrollmentactive; -
returns the immutable run context.
Early exits:
| Condition | Return |
|---|---|
Not active, or no context |
|
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
-
step.sleep("wait-<tag>", "<delayHours><h|m>")whendelayHours > 0. The unit comes fromdelayUnit. -
step.run("record-wait-<tag>")→markRunningthenrecordWait, 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
gateBigqueryViewand at least one condition, else throws; -
with no
contact.hubspotContactIdthere is nothing to scope the query by, so every condition is reported as not met — the gate fails closed; -
otherwise runs
queryGateConditionsagainst 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 |
|---|---|
|
Move to |
|
Re-enter the same gate after |
|
Mark the enrollment |
|
Mark the enrollment |
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.