Guarantees

Appends are effectively-once, consumers are at-least-once, and retries keep event identity across crashes.

In short

  • Appends are effectively-once. Event ids deduplicate writes within a session. A batch commits before its consumers start; they may start before append resolves.
  • Consumers are at-least-once. They may run again after a crash, so side effects use idempotency keys.
  • The log stays ordered. Every session has one immutable append order.
  • Consumers are concurrent by default. Log order does not serialize them. Only events in the same lane of one consumer run one at a time in log order.
  • Every consumer keeps its own place. One consumer's failures, retries, and dead letters never hold up another.

One event, many attempts

A consumer keeps a durable cursor in every session: where it has gotten to, and the event it runs next, its head. An event is pending for a consumer until that consumer's cursor moves past it. Every wakeup (an append, a scheduler watchdog, or server.drain()) claims the session's eligible heads, and the cursors decide what remains. Reads never dispatch consumers.

Each claim expires. If its holder dies, the claim lapses and the next wakeup claims the head again with a larger ctx.attempt. The retry gets the same event id, index, type, and payload. A kill after the claim can use up an attempt before your code runs, and that attempt counts toward the head's failure budget. An older attempt can never overwrite a newer one's completion or failure.

Immediate and dependent events

ctx.session.append(name, ...events) commits immediately: its events stay in the log if the parent later crashes, and run concurrently with it unless both share a lane. Returned events commit with the cursor's advance past the parent, in one transaction.

If run throws or dies before that transaction, neither the completion nor the returned events exist, and the retry produces the same deterministic ids. If the transaction commits but its acknowledgement is lost, the retry gets the committed rows back. A partial or changed batch is rejected.

The transaction covers only A2's log. An HTTP call, database write, or other external effect can't join it, so a crash after the effect but before completion repeats it. Use event.id as an idempotency key, with the session when ids can repeat across sessions (see Consumers can run twice), or add fencing in the external system.

Lanes

Lanes are serial groups of one consumer in one session. Only the lowest-index unfinished event in a lane is eligible, so a failure blocks later events in that lane, not the whole session and not other consumers. Unlaned events and different lanes run concurrently. A2 stores each consumer's lane at append, so a changed lane resolver affects only new events.

A lane controls A2 dispatch, not the outside world. A2 claims every eligible event, with no global concurrency limit or priority queue. Put capacity limits near the constrained resource, such as an API client, connection pool, or runtime.

Append and scheduling

A top-level append commits before any wakeup. A2 then starts the consumers inline and, with a scheduler, arms a watchdog in parallel. Consumers never wait for the queue, and one may start before append returns. append waits up to two seconds for the watchdog send, never for consumers. A failed arm records a2.append.armed = false on the trace. Consumer appends and returned events join the running drain and add no arm.

append.dispatch skips the inline drain and hands the work to the scheduler instead.

An event no consumer takes starts no work. It starts no drain or arm unless older work in the session is still pending. A dead letter is not pending: it waits for a redrive, which wakes the session itself.

Causal trees are durable

Every event a consumer appends or returns stores a cause: the consumer, its lane, the triggering event's index, and the attempt. Top-level appends store null. Children of one run are siblings, nested runs add levels, and a retried child keeps its original cause.

A chain that crosses the function timeout

A function killed mid-run restarts that consumer's run from its entry point in a fresh invocation: same event, new attempt. Events a cursor already moved past stay done, so a chain of events can outlast one invocation, but each run must fit in a fresh one: an attempt that runs out of time is a failed attempt, and a run that never fits dead-letters after ten. Split longer work into smaller events or increase the function duration. On Vercel, A2 caps the claim window at the function deadline when the platform exposes it, so a retry starts promptly after a timeout. Once the deadline passes, A2 claims nothing more in that invocation, so a timeout costs the event one attempt.

When a consumer keeps failing

A caught failure counts against that consumer's head, releases its claim, and leaves other eligible heads running. A2 holds it back for a delay that starts at one second and doubles with each failed attempt up to 30 seconds, then retries it on the session's next wake. With a scheduler, that's a few seconds at first, then the delay itself. An attempt that ends before it finishes, because its process timed out, crashed, or lost its claim, counts too, once the next attempt takes its claim over. Ten failed attempts dead-letter the event for that consumer: later events in its lane stay blocked, while unlaned events, other lanes, and other consumers continue. A crash costs every event it interrupts one attempt, so a session whose work keeps outrunning its function dead-letters: bound its fan-out, or put work that waits on the same resource in one lane. A NonRetriableError, or a returned batch the store refuses because it reuses an event id, dead-letters the event on that first attempt instead: a retry would fail the same way. The event waits until you retry or skip it with session.redrive().

Limits

SituationResult
Process dies before the first arm is durableRetry with the same ids, append again later, or call drain().
No scheduler configuredA later top-level append or explicit drain() wakes the session. Reads never do.
Scheduler invocation dies after claiming but before armingIts unacknowledged queue delivery is the slower fallback.
Store backend unavailableSafe progress stops until it returns.
External effect succeeds before A2 completionThe consumer may repeat it. Use idempotency or external fencing.

What append never throws for

A failing consumer never makes append throw. Its failures are retried and, if persistent, dead-letter the event for that consumer.

Append success means the event is durable, not that its consumers finished. An identical batch retry returns rows with the same ids. A mixed batch throws PARTIAL_DUPLICATE_BATCH.