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
appendresolves. - 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
| Situation | Result |
|---|---|
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call drain(). |
| No scheduler configured | A later top-level append or explicit drain() wakes the session. Reads never do. |
| Scheduler invocation dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
| Store backend unavailable | Safe progress stops until it returns. |
| External effect succeeds before A2 completion | The 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.