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
appendresolves. - 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
| 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 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.