Guarantees

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

In short

  • Appends are effectively-once. Event ids deduplicate writes. A batch commits before its handlers start; they may start before append resolves.
  • Handlers 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.
  • Handlers are concurrent by default. Log order does not serialize them. Only events in the same lane run one at a time in log order.

One event, many attempts

An event is pending until A2 marks it processed. Every wakeup (an append, a scheduler watchdog, or server.drain()) claims the session's eligible events, and the log decides what remains. Reads never dispatch handlers.

Each claim expires. If its holder dies, the claim lapses and the next wakeup claims the event 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 event'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 parent's completion, in one transaction.

If the handler 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 or add fencing in the external system.

Lanes

Lanes are session-scoped serial groups. Only the lowest-index unfinished event in a lane is eligible, so a failure blocks later events in that lane, not the whole session. Unlaned events and different lanes run concurrently. A2 stores the 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 handlers inline and, with a scheduler, arms a watchdog in parallel. Handlers never wait for the queue, and a handler may start before append returns. append waits up to two seconds for the watchdog send, never for handlers. A failed arm records a2.append.armed = false on the trace. Handler 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 type without a handler settles in the append transaction. It starts no drain or arm unless older handled work in the session is still pending.

Causal trees are durable

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

A chain that crosses the function timeout

A function killed mid-handler restarts that handler from its entry point in a fresh invocation: same event, new attempt. Events already processed stay processed, so a chain of handlers can outlast one invocation, but each handler must fit in a fresh one: an attempt that runs out of time is a failed attempt, and a handler 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 handler keeps failing

A caught failure counts against that event, releases its claim, and leaves other eligible events 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: later events in its lane stay blocked, while unlaned events and other lanes 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 handler may repeat it. Use idempotency or external fencing.

What append never throws for

A failing handler never makes append throw. Handler failures are retried and, if persistent, dead-letter the event.

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