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

vault.ts
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
    },
  },
})
  1. withdraw reserves on the lane, so no other withdrawal can race the balance check. The balance drop, the pending entry, and the transfer event commit atomically: the withdrawal cannot half-start.
  2. transfer does the slow part off the lane, so deposits keep flowing. It never changes state, and it reports failure by sending refund, not by throwing.
  3. settle and refund finish. Both return when pending[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.