Deadlines and timers

Schedule a typed event back to the same instance. Deadlines survive deploys and land on the lane like any other message.

A deadline is a message, delivered later

escrow.ts
import { actor } from 'experimental-a2/actor'
import { vercelQueues } from 'experimental-a2/scheduler-vercel'

interface Escrow {
  state: { held: Record<string, number> }
  events: {
    hold: { offerId: string; amount: number }
    accept: { offerId: string }
    release: { offerId: string }
  }
}

export const escrow = actor<Escrow>({
  name: 'escrow',
  state: { held: {} },
  scheduler: vercelQueues(),
  handlers: {
    hold: async (ctx, input) => {
      ctx.state.held[input.offerId] = input.amount
      await ctx.schedule.release({ offerId: input.offerId }, { delay: '1h' })
    },
    accept: (ctx, input) => {
      if (ctx.state.held[input.offerId] === undefined) return
      delete ctx.state.held[input.offerId]
      // your payout; ctx.id is the idempotency key
    },
    release: (ctx, input) => {
      if (ctx.state.held[input.offerId] === undefined) return
      delete ctx.state.held[input.offerId]
      // your refund; ctx.id is the idempotency key
    },
  },
})

ctx.schedule mirrors ctx.send, one method per declared event, with a timing argument: exactly one of { delay: '1h' } or { at: date }. It requires the scheduler option. The deadline outlives the invocation that armed it and any deploys in between, and delivery lands on the instance's lane like any other message.

Adapters, delay limits, and delivery semantics are the session timers underneath.

Stale timers are no-ops

When accept wins, the release timer still fires an hour later, meets the held[offerId] === undefined guard, and does nothing. When the timer wins, a late accept meets the same guard. There is no cancellation API, and the log records that the timer fired: let the event land and return when it no longer applies.

Identity and retries

A timer's name defaults to the target event's name, scoped to the triggering message, so a re-run of a crashed handler addresses the same timer instead of arming a second. One handler run gets one timer per target event; to arm several toward the same event, name them apart:

// inside a handler:
await ctx.schedule.release(
  { offerId: 'offer-42' },
  { delay: '30m', name: 'first-nudge' },
)

Duplicate deliveries fold into one append, and relative delays anchor to the triggering message's createdAt; see Identity and retries.

Scheduling is immediate

From outside a handler

// anywhere on the server:
import { escrow } from '@/escrow'

await escrow.actor('offer-42').session.schedule(
  'expire-offer',
  { at: new Date('2030-01-01T00:00:00Z') },
  { type: 'release', payload: { offerId: 'offer-42' } },
)

Outside handler contexts, schedule through the instance's session with an explicit name, timing, and event. Root scheduling semantics apply ({ at } for retry-stable targets); see Schedule from ordinary server code.