VII Patterns

Work on the Clock

Some requirements are about time, not about a request: a voucher is valid until Friday; an unpaid order is cancelled after an hour; every customer gets three free deliveries a month; the stock figures must match what the warehouse really has.

The reflex is a timer: setTimeout in the operation that creates the order, or a nightly job that walks over every customer. A timer in memory is gone with its process, and exists once per replica while it lives. A job that must touch every record at midnight is slow, and when it fails halfway nobody knows which half was done.

There are four ways to do it that survive restarts. Try them in this order; the first that fits is the cheapest.

§Expiry is data, not a job

Nothing has to happen when a voucher expires. It only has to stop working. Store the moment and let whoever uses the voucher compare. The voucher is that of store.vouchers (Ask the Owner, or Keep a Copy), whose redeem counts its uses down; it gains a property, an error and a line of code:

# components/store.vouchers/manifest.toa.yaml
entity:
  properties:
    # id, discount and left stay
    expires: { type: integer }   # milliseconds since the epoch

operations:
  redeem:
    concurrency: retry
    once: true
    query: true
    errors: [EXHAUSTED, EXPIRED]
// components/store.vouchers/operations/redeem.ts
import type { Context, State } from '../types/index.d.ts'

export async function transition (input: null, entry: State, context: Context) {
  if (entry.expires < context.now()) return new Error('EXPIRED')

  if (entry.left < 1) return new Error('EXHAUSTED')

  entry.left -= 1

  return { discount: entry.discount }
}

No flag is switched at midnight, so no flag can be late, lost or switched twice. The voucher ends by itself, at the millisecond, on every replica. context.now() is the clock of an operation.

Store the moment, not a status that somebody must remember to change. Serve expires to clients too and let them compare; an active: true in a reply is out of date the moment it is sent. Extending is one write of a later moment. The same goes for a premium membership, a trial, a temporary block, a quote that holds for ten minutes.

§Catch up when it is used

Three free deliveries a month sounds like a job on the first of each month that resets a counter for every customer. It does not need one. Keep the month the counter belongs to, and bring it up to date whenever the counter is used:

// components/loyalty/operations/deliver.ts
import type { Context, State } from '../types/index.d.ts'

const FREE = 3

export async function transition (input: null, entry: State, context: Context) {
  const month = new Date(context.now()).toISOString().slice(0, 7)   // '2026-10'

  if (entry.month !== month) {
    entry.month = month
    entry.free = FREE
  }

  if (entry.free < 1) return { free: false }

  entry.free -= 1

  return { free: true }
}
# components/loyalty/manifest.toa.yaml
entity:
  properties:
    # points and tier stay
    month: { type: string }
    free: { type: integer, minimum: 0 }

operations:
  deliver:
    concurrency: retry
    once: true
    query: true

The counter is kept with the customer’s other perks, in loyalty: one object per customer under the customer’s id, which nothing has to create (Keep Data About a Customer without Creating It). deliver subtracts, so it declares once.

A customer who orders nothing in March is never touched in March, and the first delivery in April finds an old month and starts a fresh count. The reset sets the count for a named month, so doing it twice changes nothing. Anything that shows the count applies the same rule when it reads: a counter from an older month is a full one.

This works whenever the new value follows from the stored one and the clock: monthly allowances, interest, a streak of days.

§Sweep on a pulse

An unpaid order does have to change when its hour is over: its cancelled event releases the stock and whatever was held for it. Something has to act, and nobody is calling.

Declare a pulse, a call the component makes to itself at a regular interval, and let the operation look for what is due:

# components/store.orders/manifest.toa.yaml
cadence:
  expire: 300   # every five minutes

operations:
  expire:
    type: effect
    input:
      type: object
      properties:
        n: { type: integer }
        i: { type: integer }
// components/store.orders/operations/create.ts
import type { Context, CreateInput, State } from '../types/index.d.ts'

const HOUR = 3600 * 1000

export async function transition (input: CreateInput, entry: State, context: Context) {
  if (input.total > context.configuration.limit)
    return new Error('LIMIT_EXCEEDED')

  Object.assign(entry, input)
  entry.deadline = context.now() + HOUR

  return entry
}

create is the operation Add Configuration writes, with the deadline added. cancel, which takes a reason, is a step of A Process Across Components, and context.local calls an operation of the same component.

// components/store.orders/operations/expire.ts
import type { Context, ExpireInput } from '../types/index.d.ts'

export async function effect (input: ExpireInput, context: Context) {
  const due = await context.local.enumerate({
    query: { criteria: `status==pending;deadline<${context.now()}`, limit: 100 }
  })

  for (const order of due)
    await context.local.cancel({ query: { id: order.id }, input: { reason: 'EXPIRED' } })
}

deadline is an integer property of the order; add an index for the query. In a test, call expire directly instead of waiting for it (Write a Test).

Three properties make this reliable, and they are the rules for every sweep:

  • It selects by state, not by time passed. “Every pending order past its deadline”, never “orders that expired in the last five minutes”. A pulse that was missed — a deployment, an outage — costs a delay, and the next one does its work as well.
  • It does a bounded step. A hundred orders, then it ends. After a long outage the backlog drains over a few pulses instead of in one call that never finishes.
  • The decision is made by the transition. expire only finds candidates. cancel reads each order again and decides, so an order paid a second ago is not cancelled:
// components/store.orders/operations/cancel.ts — the part that matters here
if (input.reason === 'EXPIRED' && entry.status !== 'pending') return new Error('NOT_PENDING')

cancel declares NOT_PENDING among its errors for that. An order is cancelled up to five minutes after its hour. For most deadlines that is fine, and it is the price of a mechanism that heals itself.

§Or arm a delay for each object

Where the moment has to be exact, or a sweep would select from millions of records to find three, give each order its own alarm: context.delay hands over one call to be made later, and it is stored, so it survives restarts.

// components/store.orders/operations/create.ts — in place of the deadline
entry.alarm = await context.delay(
  'store.orders.cancel',
  { query: { id: entry.id }, input: { reason: 'EXPIRED' } },
  { interval: HOUR, overdue: null }
)

alarm is a string property of the order: the id context.delay answers, which cancels the delay. A delay needs the cadence service running, which the application toa create wrote does not start: without it the call that arms one never answers. Start it.

The target still checks the state it finds, exactly as above: the alarm may ring for an order that was paid long ago. A delay that passes its overdue bound is dropped and nothing makes it up, which is why overdue: null is chosen here — and why a sweep, which has nothing to drop, remains the safer of the two.

A sweep on a pulseA delay per object
When it actsat the next pulseat its moment
After an outagethe next pulse catches upmade late, or dropped if overdue says so
Costa query every intervalone stored call per object
Changing the rulechange the querycalls already armed keep their old moment

§Reconcile with the truth outside

The warehouse system knows what is on its shelves. fulfilment.stock keeps figures for the shop’s pages and follows them by events, but goods are also counted, broken and found without the shop hearing of it. Events alone cannot repair what nobody announced.

Add a pulse that reads the truth and sets the copy to it:

// components/fulfilment.stock/operations/sync.ts — on a pulse, cadence: { sync: 900 }
import type { Context, SyncInput } from '../types/index.d.ts'

const GRACE = 10 * 60 * 1000

export async function effect (input: SyncInput, context: Context) {
  const lines = await context.remote.fulfilment.warehouse.inventory({ input: { limit: 1000 } })

  for (const line of lines) {
    const item = await context.local.observe({ query: { id: line.product } })

    if (item.UPDATED > context.now() - GRACE) continue   // just changed here: not settled yet

    if (item.available === line.quantity) continue

    await context.local.assign({
      query: { id: line.product },
      input: { available: line.quantity }
    })
  }
}
# components/fulfilment.stock/manifest.toa.yaml
cadence:
  sync: 900   # every fifteen minutes

operations:
  sync:
    type: effect
    input:
      type: object
      properties:
        n: { type: integer }
        i: { type: integer }
  assign:
    input:
      type: object
      properties:
        available: { type: integer, minimum: 0 }
      additionalProperties: false

fulfilment.stock keeps available for each product (Two Writers at Once). It is associated with the product, so a product seen for the first time simply gets its figure. The inherited assign takes no input until the manifest says which properties it may set. fulfilment.warehouse is the component that speaks to the warehouse’s system (A Process Across Components); inventory is one more operation of it, which answers what is on the shelves as a list of { product, quantity }.

It sets a figure, so running it twice or late is harmless. It skips what changed moments ago, because the outside system may not have heard of that change yet, and “correcting” it would undo it. The events keep the copy fresh; the pulse keeps it right. Use both whenever the copy is of something the application does not own (Mirroring a Provider’s Object).

§Find work that stopped

In a process of several steps, a step can stop without a trace: the warehouse never answered, and the order has been approved since yesterday. A sweep finds it by the same means — select by state and age. pack and cancel are the steps of that process; fulfilment.warehouse.status is one more operation of the component that speaks to the warehouse, which answers whether the order was picked:

// components/store.orders/operations/revive.ts — on a pulse
import type { Context, ReviveInput } from '../types/index.d.ts'

const LIMIT = 6 * 3600 * 1000

export async function effect (input: ReviveInput, context: Context) {
  const stuck = await context.local.enumerate({
    query: { criteria: `status==approved;UPDATED<${context.now() - LIMIT}`, limit: 100 }
  })

  for (const order of stuck) {
    const pick = await context.remote.fulfilment.warehouse.status({ input: { order: order.id } })

    if (pick.done) await context.local.pack({ query: { id: order.id } })
    else await context.local.cancel({ query: { id: order.id }, input: { reason: 'STUCK' } })
  }
}

Silence is not a failure. A step that did not report may have done its work and lost only the report. Ask whoever was doing it before undoing it: here the order turns out to be picked, or is cancelled and refunded, and in both cases it moves again.

§What can still go wrong

Two replicas run the sweep. A pulse is made by one replica at a time, however many run, and is not called again while its previous call is still running. If it does overlap in a rare moment, the second cancel of the same order finds it cancelled and changes nothing, because the decision is in the transition.

The sweep reaches outside. Where a sweep creates something in another system — books a courier, starts a machine — two runs at once would create two. That is the one place for a lock, and the state is read again once the lock is held. Do not lock around changes to entities.

A sweep fails halfway. It threw on the fortieth order. Nothing is retried, and nothing needs to be: the next pulse selects what is still due, which no longer includes the first thirty-nine.

Every replica has to act. A cache in memory or a connection held by one process is that process’s own to look after: declare the pulse with scope: replica.

The clock inside a transition. A transition can run more than once for one call, and context.now() answers a later moment each time. Compare with it and store deadlines computed from it; do not let the outcome depend on its exact value.

The report on Monday at nine. A calendar time is a schedule, not a pulse: it is called late instead of never, with the moment it was due, and the report is computed from that moment.

§Checklist

  • Something that only has to stop being valid is a stored moment, compared when it is used.
  • Something that renews by the calendar is brought up to date at its next use, by a named period.
  • Something that must happen is found by a pulse that selects by state and takes a bounded step.
  • The sweep finds candidates; a transition decides for each.
  • A delay per object is for exact moments, and its target checks the state anyway.
  • A copy of an outside truth is corrected on a pulse, with a grace period for fresh changes.
  • A step that went silent is asked about before it is undone.
  • No timer in memory for work that must not be lost.
Search
Search the documentation