Timeouts and projection
The server maintains three timeout lists and four internal transitions that fire against them. None of these are user-visible API calls; they are background machinery the server runs on its own clock. They are also where the most important correctness property in the machine lives: the projection convention, which means that what a handler returns about a promise and what is actually written in storage can legally diverge — and your implementation must handle both sides correctly.
The three timeout lists#
ServerState carries three separate timeout lists (state.lean, lines 62–76):
structure PromiseTimeout where
id : String
timeout : Nat
structure TaskTimeout where
id : String
kind : Nat -- 0 = pending retry, 1 = lease expiration
timeout : Nat
structure ScheduleTimeout where
id : String
timeout : NatpromiseTimeouts is armed when a promise is created — but only if the promise is external. A promise is external when it carries a resonate:target tag (meaning a task backs it) or a resonate:timer = "true" tag (state.lean, lines 34–35):
def PromiseObject.external (p : PromiseObject) : Bool :=
p.tags.has "resonate:target" || p.isTimerPromises that carry neither tag — pure callback rendezvous points created by the SDK to coordinate internal workflows — are never added to the promise timeout list. Their only settlement path is an explicit promise.settle call from a worker.
One exception to the external-only rule: task.create (T-02-task.create.lean, fresh-promise branch) calls setPromiseTimeout unconditionally, with no externality check — every task-backed promise created through T-02 gets a timeout entry. The tag-gated arming described above is promise.create's (P-02) behavior.
taskTimeouts carries two logically distinct entries, distinguished by kind:
kind = 0: pending-retry timeout. Set when a task is first created or returned topending. FiresonTaskRetryTimeout, which re-issues the execute message.kind = 1: lease-expiration timeout. Set when a task is acquired. FiresonTaskLeaseTimeout, which reverts the task to pending and re-issues the execute message.
scheduleTimeouts holds one entry per schedule, set to nextRunAt. Fires onScheduleTimeout.
The four internal transitions#
onPromiseTimeout#
02-timeouts.lean, lines 8–41.
Fires when a promise timeout entry matures. Guard: if the promise does not exist, or is not pending, this is a no-op. Otherwise:
- Settle the promise —
resolvedifp.isTimer,rejectedTimedoutotherwise.settledAtis set top.timeoutAt(the original deadline), notnow. - Delete the promise timeout entry.
- If a task exists for this promise id: transition it to
fulfilled, clearpid/ttl/resumes, and delete its task timeout entries. - Settlement scrub: scan every pending promise in the store and remove this promise's id from their
callbackslist. A timed-out promise can never resume an awaiter; callbacks pointing to it would stall forever without this step. - Send an
unblockmessage (carrying the settled promise record) to every registered listener address via the outbox. - Call
enqueueResumefor every registered callback (awaiter promise id) — transitioning suspended awaiters back topendingand re-issuing their execute messages.
Step 4 is the settlement scrub; steps 5–6 are the settlement propagation (listeners, then callbacks). The same three-step block runs identically in promiseSettle and taskFulfill. onPromiseTimeout is the only writer that executes it for expired promises.
onTaskRetryTimeout#
02-timeouts.lean, lines 43–57.
Fires when a pending task's retry timeout (kind 0) matures. Guard: task must exist and be in pending. Otherwise no-op.
- Delete the existing kind-0 timeout entry.
- Set a new kind-0 timeout at
now + retryTimeout(default 5000 ms fromServerConfig.retryTimeout). - Re-send an
executemessage to the task's target address (taken fromresonate:targeton the promise).
This is the mechanism that re-offers unclaimed work. A pending task with no worker will be re-dispatched every retryTimeout milliseconds until it is acquired or its promise settles.
onTaskLeaseTimeout#
02-timeouts.lean, lines 59–75.
Fires when an acquired task's lease expires (kind-1 timeout). Guard: task must exist and be in acquired. Otherwise no-op.
- Revert the task to
pending— clearpidandttl. - Delete the existing kind-1 timeout entry.
- Set a new kind-0 (pending-retry) timeout at
now + retryTimeout. - Re-send an
executemessage to the target address.
The version is not incremented here. It advances on the next task.acquire. A worker that held the lease at version n and lost it will find its next mutating operation rejected with 409 once another worker acquires at version n+1. See Implementing tasks.
onScheduleTimeout#
02-timeouts.lean, lines 88–97.
Fires when a schedule's fire time arrives. Runs the catchUp loop (which fires promiseCreate for every missed occurrence), writes the updated schedule, disarms the current timeout entry, and arms a new one at nextRunAt. See Implementing schedules for the full catch-up semantics.
The projection convention#
This is the hardest property to reason about correctly, and the one most likely to bite you if you skip it.
Several handlers return a promise's state computed from the stored record and the current clock — without writing the computed state to storage. This is the projection convention. It appears in promise.get, promise.create (when the promise already exists), and promise.settle. All three follow the same pattern:
If a stored promise has
state = pendingandtimeoutAt ≤ now, the handler returns a projected settled record —resolvedfor timer promises (resonate:timer = "true"),rejectedTimedoutfor all others, withsettledAt = p.timeoutAt— without writing that record to storage.
The actual write to storage happens only when onPromiseTimeout fires.
Promise created: { state: pending, timeoutAt: T }
|
... time passes, no timeout handler fired yet ...
|
now = T+1 |
▼
promise.get returns: { state: rejectedTimedout, settledAt: T }
storage still holds: { state: pending, timeoutAt: T }
|
now = T+N |
▼
onPromiseTimeout fires: writes { state: rejectedTimedout, settledAt: T }
sends unblock to listeners
enqueues resumes for callbacksWhere settlement is actually written#
Settlement is written to storage in exactly three places, all of which run the same settlement scrub afterward:
promiseSettle(P-03) — direct settle by an API caller, whenp.timeoutAt > now. If the promise is already expired (p.timeoutAt ≤ now),promiseSettlereturns200with the projected state and does not write.taskFulfill(T-07) — task completes and settles its promise atomically, whenp.state == pending && p.timeoutAt > now. If the promise is expired,taskFulfillreturns409— the task cannot fulfill against an already-projected promise.onPromiseTimeout— the background transition that is the only writer for expired promises. This is what actually delivers the settlement scrub, listener notifications, and callback resumes for timed-out promises.
Implementation consequences#
Your storage layer must not assume read-your-writes symmetry on promise state. A query for "all settled promises with settledAt < T" will miss promises that have logically expired but haven't been processed by onPromiseTimeout yet. A query for "all pending promises" may include promises that would project as settled to any handler that reads them. Both queries are internally consistent — they reflect different moments in the machine — but you must be explicit about which you're asking and why.
The timeout machinery is a correctness component, not a janitor. If onPromiseTimeout never fires:
promise.getreturns correct results — projection handles it.- Listeners never receive their
unblockmessage. - Awaiting tasks never receive their resume.
- Work silently stalls — no error, no crash, nothing happening.
An implementation that skips or indefinitely delays promise timeout processing passes every read-path test and fails every durability test. Build the timeout dispatch loop with the same care as the write handlers, and test it explicitly: create a promise with a short timeoutAt, let it expire, verify that the listener receives unblock and the awaiting task transitions to pending.
If your storage layer builds an index over the state field for efficient lookups and you answer promise.get from that index, expired promises will return pending when they should return rejectedTimedout. Projection must happen in handler logic — after the read, before the response — not in the index. Any pending promise whose timeoutAt ≤ now must be projected at the point of response construction, even if the storage record still says pending.
Cross-references#
- Implementing promises —
promiseSettle,promise.create, and where promise timeouts are armed - Implementing tasks —
taskFulfill, version increment on re-acquire, lease and retry timeout lifecycle - Implementing schedules —
onScheduleTimeoutand the catch-up loop - Outbox and delivery —
unblockandexecutemessages emitted by timeout transitions - State model —
PromiseTimeout,TaskTimeout,ScheduleTimeoutstruct definitions
Verified against resonate-specification@83d64c3.