Concurrency
Each instance processes one message at a time. Slow work goes in concurrent handlers, which run in parallel and report back by sending events.
One message at a time
Every message runs on the instance's lane: one handler at a time, in arrival order. A handler sees the state every previous message produced, no other message can race its checks, and its changes commit before the next message runs.
Lanes are per instance: a thousand vaults process in parallel, each in order. If one instance gets more traffic than one queue can absorb, split the identity (per user, per order, per document) instead of making one instance concurrent.
Concurrent handlers
A serial handler holds the lane while it runs: if withdraw awaits a
two-second bank API, every deposit waits behind it. Keep decisions
serial and move the slow part into a handler wrapped in
concurrent(...). It leaves the lane and runs in parallel with serial
handlers and other concurrent ones. Inside it:
ctx.state()returns a snapshot, which can go stale while the handler runs. There is no draft to mutate.- State changes happen by sending events, whose serial handlers decide against current state.
Reserve, transfer, settle
import { NonRetriableError } from 'experimental-a2'
import { actor, concurrent } from 'experimental-a2/actor'
interface Vault {
state: {
balance: number
/** In-flight transfers: ref to reserved amount. */
pending: Record<string, number>
}
events: {
deposit: { amount: number }
withdraw: { amount: number }
transfer: { ref: string; amount: number }
settle: { ref: string }
refund: { ref: string }
}
}
export const vault = actor<Vault>({
name: 'vault',
state: { balance: 0, pending: {} },
handlers: {
deposit: (ctx, input) => {
ctx.state.balance += input.amount
},
// the reserve: serial, so the guard sees fresh state
withdraw: (ctx, input) => {
if (ctx.state.balance < input.amount) {
throw new NonRetriableError('insufficient funds')
}
ctx.state.balance -= input.amount
ctx.state.pending[ctx.id] = input.amount
ctx.send.transfer({ ref: ctx.id, amount: input.amount })
},
// the slow part: off the lane, in parallel
transfer: concurrent(async (ctx, input) => {
// your slow call; ctx.id makes retried attempts idempotent:
// const ok = await bank.transfer(input.amount, { idempotencyKey: ctx.id })
const ok: boolean = true
if (!ok) {
ctx.send.refund({ ref: input.ref })
return
}
ctx.send.settle({ ref: input.ref })
}),
// finalizers: serial again, idempotent by the pending guard
settle: (ctx, input) => {
if (ctx.state.pending[input.ref] === undefined) return
delete ctx.state.pending[input.ref]
},
refund: (ctx, input) => {
const amount = ctx.state.pending[input.ref]
if (amount === undefined) return
delete ctx.state.pending[input.ref]
ctx.state.balance += amount
},
},
})withdrawreserves on the lane, so no other withdrawal can race the balance check. The balance drop, thependingentry, and thetransferevent commit atomically: the withdrawal cannot half-start.transferdoes the slow part off the lane, so deposits keep flowing. It never changes state, and it reports failure by sendingrefund, not by throwing.settleandrefundfinish. Both return whenpending[ref]is gone, so a duplicate or late message does nothing. The refund amount comes from state.
A crash re-runs only that message from the state it started with, so the vault always lands reserved, settled, or refunded.
Buffered sends
ctx.send buffers. A serial handler's sends commit with its state
change; a concurrent handler's commit when it finishes. A refusal
discards the buffer with the state changes, so a refused message leaves
no trace. Re-runs produce the same message ids, so a retried handler
never appends its sends twice. ctx.schedule is
not buffered.
When handlers fail
A concurrent handler that throws retries off the lane, and nothing waits for it. Serial handlers fail as described in Refusals.
call works on serial events only. A concurrent handler produces no
answer, so calling one is rejected: send those events instead.