Decision records
ADR 0118 — A conditional fence: acquire-if-parked
- Status: Accepted — implemented (2026-10-06; proposed 2026-10-05, design approved after 4 review rounds). See “Implementation (as built)” for what differs from the text above (SQLite does not fence, the Postgres acquire is one round-trip and a park ensures its fence row, the supervisor keeps its unfenced first try,
app.supervise/app.redriveare the wiring) and the acceptance benchmark. - Date: 2026-10-05
- Closes: R8k (the R8 review’s residual TOCTOU, named in the
@loomwork/orchestrationREADME, “Restart safety (R8)”) - Builds on: K9.4 (ADR 0021,
acquireFence), ADR 0117 (locatePark,viewDigeston a park), ADR 0055 (the retention pin predicate), ADR 0074 (a decorator must decide every optional SPI method), ADR 0111 (EXECUTION_STARTED.parent). - Placement: EventStore SPI (optional method) + a
RunOptionsfield in workflow-runtime + one optionalTimerRecordfield (D4); the retention half is 1P (@loomwork/orchestration) over the existing pin seam. EVENT_STORE_SPI 2.0.0 → 2.1.0 (additive MINOR). KERNEL_ABI and EVENT_SCHEMA are not touched by this ADR. - Revision: round 3 — the conditional is now on the fence token AND on the park being unanswered, atomically
(
{ fence, parkSeq }): a retry on the returned token alone reopened window 2 (a live resumer’s append does not move the token);contendedis its own retryable class with a live re-driver (D4). Round 2 — D2’s retry uses the store-returned current token (the envelope token is stranded by any dead acquire) and exhaustion is a retryablecontended, notnot-suspended. Round 1 (Opus review of PR #495). The first draft compared the log head, premised on “the park is by construction the last event”. That is false: the runtime appends an unfencedEXECUTION_DENIEDafter a park (recordExecutionDenied,WorkflowRuntime.ts~664–700, “appends WITHOUT a fence”),recordOutcome(ADR 0116) and audit events do too, so a head comparison would strand parents that merely had a denial recorded, and window 1 would not close. The condition is now the fence token (D1). This ADR no longer depends on R3k for the fence half and lands before R2 (ADR 0110’s notify-resume uses it).
Context
EventStore.acquireFence(executionId) is unconditional: it bumps the execution’s fence token and
returns it, and from then on a store that fences rejects any append with a lower token. The child
supervisor (R8) resumes a parked parent when its children settle, from any number of nodes. It reads
the park, acquires a fence, appends the resume. Two windows remain that no amount of retrying in 1P
closes:
- Between acquire and append a rival supervisor fences too; the first one’s resume fails stale.
Harmless but noisy (bounded to two acquisitions, then
onError, retried byrecover()). - Between the durable resume and the read-back a rival’s
acquireFencesupersedes the holder of the already running parent body: the legitimate runner’s next append is rejected. This is the dangerous one: a live execution is fenced out by a supervisor whose resume had already lost.
Both exist because “fence the parent” and “the parent is still parked on the thing I am answering” are two operations. What is needed is one atomic one.
A separate, smaller gap sits beside it: LogLifecycle.applyRetention can archive or delete a child
that has finished while its parent is still parked on it, so the parent’s later awaitChild reads a
missing log. R8 treats a deleted member as a typed failure, which is correct, but retention should not
create that failure.
Decision
D1 — acquireFenceIf: a conditional acquire on the fence token
An optional EventStore method:
acquireFenceIf?(executionId: string, expect: { fence: number; parkSeq: number }):
Promise<{ fence: number } | { stale: true; fence: number }>;
Atomically: if (a) the execution’s current fence token equals expect.fence and (b) no answering
event exists with sequence > expect.parkSeq, bump and return the new token; otherwise return
{ stale: true, fence: <current> } and change nothing. (b) is what makes a retry on a re-read token safe
(D2): a token read from a stale view cannot be attributed to a dead acquirer or to a live resumer whose
body is already running, but “a resume for this park is already in the log” can be, and a resume’s append
does not move the token.
Answering events (an allow-list, fixed in @loomwork/event-runtime and shared by every backend and the
conformance kit). Counts: EXECUTION_RESUMED (any payload, including the null status-only re-open of
ADR 0104: it still moves the state from parked to running, which is what a second resumer must not
override), EXECUTION_SUSPENDED (a later park supersedes this one), and the terminal kinds
EXECUTION_COMPLETED, EXECUTION_FAILED, EXECUTION_CANCELLED and EXECUTION_CONTINUED (ADR 0109).
Ignored, because they are audit appends that follow a park without answering it: EXECUTION_DENIED
(unfenced, recordExecutionDenied), OUTCOME_RECORDED (ADR 0116, terminal-only anyway), STEP_FAILED
while parked (ADR 0105), MESSAGE_CONSUMED (ADR 0110: it is written by the running owner, before a park or
after the EXECUTION_RESUMED that answers one, so it never answers by itself), EXECUTION_MIGRATED,
annotations and every other KERNEL kind. The classification is a required Record<EventKind, "answering" | "ignorable">
in @loomwork/event-runtime (no default: an unclassified kernel kind is a compile error, so adding one forces the
decision), applied by isAnsweringKind(event), which reads the kind AND the envelope’s metadata.kernel:
- a kind in
KERNEL_EVENT_KINDSis classified by its map entry; - a kind NOT in
KERNEL_EVENT_KINDSis an application event (ctx.emit, actx.streamdelta, a parallel sibling’s output) and is ignorable: counting it would let a sibling that keeps emitting after the park make everyacquireFenceIfstale forever, so the execution could never be resumed; - EXCEPT an unknown kind carrying
metadata.kernel === true: a kernel kind written by a NEWER node in a mixed fleet, which this node cannot classify. It is answering: fail-closed, it costs at worst acontended(retried by D4), never a fenced-out body. Rule for every future kernel kind: its writer MUST stampmetadata.kernel: true(an unstamped new kernel kind is indistinguishable from an app event and would be ignorable on an older node).
The SQL scan therefore filters
kind = ANY(<answering kernel kinds>) OR (kind <> ALL(<KERNEL_EVENT_KINDS>) AND metadata->'kernel' = 'true'::jsonb)
(SQLite: kind IN (…) OR (kind NOT IN (…) AND json_extract(metadata,'$.kernel') = 1)), not a
NOT IN (<ignorable list>). The token is the right
condition because it is what every legitimate writer’s append is checked against (StaleFenceError,
event-runtime/src/fencing), and it moves only when someone acquires, never when an unfenced audit
event is appended. The caller passes the token under which the park it is answering was written: events
carry it as metadata.fence (ExecutionJournal, WorkflowRuntime bootstrap), and locatePark already
yields the park envelope. A park written with no fence expects 0 (the token a never-acquired execution
has). Backends: memory (InMemoryAsyncEventStore) is one synchronous step, the fences compare-and-set plus
a binary search of the sorted log to parkSeq and a scan of the (short) tail for an answering kind.
SQLite runs it in one BEGIN IMMEDIATE transaction (single writer, so the check and the bump cannot
interleave with an append). Postgres cannot do it as one statement: its append guards the fence with a
subquery (PostgresEventStore.ts:177) and takes no lock on the fences row, so under READ COMMITTED an
append that passed the guard at token 6 could commit after a single-statement NOT EXISTS had already
looked, and the acquire would fence out a body that had just been answered. So (1) the append’s fence guard
reads the row FOR SHARE only when the appended batch contains an answering event (EXECUTION_RESUMED,
EXECUTION_SUSPENDED, a terminal kind including EXECUTION_CONTINUED, or an unknown kind stamped metadata.kernel: true; app events do not) and (2) acquireFenceIf is a transaction: SELECT fence … FOR UPDATE (waits for in-flight
appends, blocks new ones), then a separate statement scanning events for an answering kind past
parkSeq (a fresh snapshot, so it sees every committed append), then the UPDATE. Why only answering appends: fences rows are created on the first acquireFence
(PostgresEventStore.ts:445-446) and never deleted, so a FOR SHARE on every append of any ever-fenced
execution would add xmax/multixact writes and WAL to the hot path (ceiling ~5.7K exec/s, ADR 0074/0075). The
invariant (append lands implies the acquire is stale, or acquire wins implies the append is StaleFenceError)
only constrains answering appends; step, effect and audit appends keep today’s plain guard. Both append paths
take it: the single-append guard (:177) and the appendMany JOIN path (PostgresEventStore.ts:287, LEFT JOIN fences f … becomes a locking read, FOR SHARE OF f, when any batch row is answering). No deadlock: an append
locks the fence row FOR SHARE then inserts in one statement; the acquirer locks the fence row FOR UPDATE
then runs a read-only scan, so neither holds a lock the other waits on in the opposite order. Acceptance:
the R8k PR runs tools/bench-throughput on fenced and unfenced executions before and after, and records the
numbers in this ADR’s acceptance. A conformance case
races a resume append against acquireFenceIf and requires “append lands and acquire is stale” or
“acquire wins and the append is StaleFenceError”, never both succeeding. The sharded and tenant
decorators forward it. A store without it keeps today’s behaviour (the caller falls back to acquireFence and the documented
residual), and the conformance kit’s case runs only when the method exists (as copyPrefix and
truncatePrefixKeeping do).
Why the token and not the head. Winner and loser both read the same park at fence F. The winner’s
conditional acquire moves the token to F+1; the loser’s sees F+1 ≠ F → stale, whatever else was
appended to the log in the meantime (an EXECUTION_DENIED after the park does not move the token, so the
legitimate resume is not refused for it). Why also parkSeq. The token alone cannot see a committed
resume: the winner appends under its token without moving it. Once the winner has appended, a rival that
re-read the token still sees it match and would acquire, fencing out the winner’s running body.
Condition (b) refuses it.
Residual and its closure. A park answered through a path that never acquired a fence leaves the token
at F; condition (b) sees that answer now, so the conditional itself refuses. The runtime still
re-checks assertResumable under the acquired fence (the existing check that the newest park is still
unanswered, not-suspended otherwise) as defence in depth for a store without the method.
D2 — The runtime uses it on an answering resume
RunOptions.fenceIfParkedAt?: number (the park’s sequence, as locatePark supplies) on resume /
resumeAs: the runtime reads the park envelope at that sequence, takes its metadata.fence (or 0), and
acquires with acquireFenceIf(id, { fence, parkSeq }) instead of acquireFence. On { stale, fence: cur }
it re-reads the park (locatePark): if the execution is still parked on the same wait (the park is
unanswered) the token moved only because someone acquired and never appended (a crashed supervisor,
TimerService.ts:323 runtime.acquireFence, orchestration.ts:987; an acquire is never undone, so the
park envelope’s metadata.fence is stale forever), so it retries with { fence: cur, parkSeq } —
the token the store just returned, not the envelope’s, and the same parkSeq. Window 2 stays closed
by the store, not by the re-read: the interleaving “A acquires 6 and runs; B re-reads the park before A’s
resume lands; A appends its resume; B retries with cur = 6” is refused because an EXECUTION_RESUMED
past parkSeq now exists (D1 (b)), whatever the token says; B’s next re-read shows the park answered
(not-suspended, nothing appended). If instead B’s retry lands between A’s acquire and A’s append, B takes
token 7 and A’s append fails stale (window 1, a clean refusal that A’s own bounded retry re-reads); no
resume had been recorded, so no body was running. Bounded (3 attempts); exhaustion raises
ResumeContendedError, a distinct class (D4) from ResumeRefusedError: the park is still unanswered, so
“not suspended” would strand the parent. If the park is gone or is a different one it raises the existing
ResumeRefusedError reason not-suspended and appends nothing. superviseChildren, TimerService and
ADR 0110’s inbox notify-resume (which “fences up front”) pass it; the R8 supervisor’s bounded-acquisition
loop collapses to one attempt.
The inbox notify path appends [MESSAGE_CONSUMED, EXECUTION_RESUMED] as ONE atomic appendMany under the
acquired fence. EXECUTION_RESUMED is the only answering event of the pair; MESSAGE_CONSUMED stays
ignorable (a consumed message with no resume cannot answer a park, and the two never land apart, so a crash
leaves either both or neither). The re-driver therefore re-derives from the log and delivers exactly once.
D3 — Retention does not delete a child its parent is parked on
LogLifecycle is unchanged. @loomwork/orchestration exports pinParkedChildren(supervisor), a
PinPredicate (ADR 0055 D1) for retention.pinned that answers true for a child id while its parent
is non-terminal and parked on CHILD_DONE for it. The child → parent edge comes from the child’s own
EXECUTION_STARTED.parent (ADR 0111 D1; for logs written before it, from the supervisor’s rebuilt state,
so the pin half works before R3k lands and sharpens after it), and
the park from the parent’s fold via locatePark. defineConfig composes it automatically when
superviseChildren is wired, so an app does not hold the rule in its head. A pinned execution is
“left exactly where it is” (ADR 0055), which is the required behaviour; the predicate reads two
folds per candidate, so it is consulted only for executions that are otherwise retention-eligible.
D4 — contended is a retryable class with a live re-driver
ResumeContendedError is not a ResumeRefusedError (no reason): a caller that treats every
ResumeRefusedError as final must not mistake “retry me” for “refused”. It carries retryable: true,
executionId and attempts, is exported from @loomwork/workflow-runtime, and is raised only by D2’s
exhaustion. Retrying it by hand is allowed; nothing may depend on that.
It also needs a live re-driver, because recover() is app-called and a live path that merely reports
an error leaves a parked parent waiting for an operator (the R8 lesson). On contended the caller of the
resume (the supervisor’s resume path; ADR 0110’s park-time self-resume) writes a due-later re-drive
row to the existing TimerStore for the execution and returns. TimerRecord gains an optional
redrive?: { attempt: number } (additive, TimerStore is unversioned). SQL migration, guarded for
both stores (CREATE TABLE IF NOT EXISTS never adds a column, SqliteTimerStore.ts:23-26): SQLite runs
ALTER TABLE timers ADD COLUMN redrive TEXT only when pragma table_info(timers) lacks redrive; Postgres
runs ALTER TABLE timers ADD COLUMN IF NOT EXISTS redrive JSONB inside migrate(pool)
(PostgresTimerStore.ts:27). The timer conformance kit gains a round-trip case and a migration case: open an
old-shape table, then write a redrive row. wakeAt is the due time and the row is idempotent per
(executionId, wakeAt). Collision rule: the primary key plus ON CONFLICT DO NOTHING
(PostgresTimerStore.ts:33, INSERT OR IGNORE in SQLite) would drop the redrive flag when a redrive row
lands on an existing plain row. Chosen: schedule upserts redrive on conflict
(ON CONFLICT (execution_id, wake_at) DO UPDATE SET redrive = COALESCE(EXCLUDED.redrive, timers.redrive);
SQLite equivalent ON CONFLICT … DO UPDATE), so a flagged row is never downgraded and a plain schedule never
clears the flag; jitter is additionally applied but nothing relies on it. Flag: redrive rows are written
only when kernel.features.redrive is on (like inbox, off until the fleet is upgraded); flag off keeps the
old behaviour (the error is surfaced and recover() re-drives). Backoff is exponential with full jitter
(base 200 ms, doubling, capped at 30 s, jitter from the injected rng so DST is deterministic), at most
maxRedriveAttempts (default 8) rows in succession, the attempt number carried in the row.
TimerService.fire is widened: a row with redrive set is not judged by suspendedOn.wakeAt (which
would retire it as obsolete); it calls the registered re-drivers (TimerServiceOptions.redrivers, which
superviseChildren and the inbox notify path register), each of which re-derives its resume from the log
and answers done | noop | contended; contended writes the next row with attempt + 1, done/noop
completes the row, and exhaustion reports onError once and leaves the execution to recover(). The
re-driver runs under the existing lease claim, so two nodes do not both drive it.
Replay and migration
No event or fold change. A runtime on 2.1.0 with an older store falls back to acquireFence; an older
runtime ignores the optional method. The acquireFenceIf half is safe in both directions. The redrive rows
are not: an older node’s TimerService.fire judges every row by suspendedOn.wakeAt and retires a redrive
row as obsolete, a silent loss of the re-drive. That is why redrive writes sit behind kernel.features.redrive
(D4) until every node runs 2.1.0; with the flag off nothing new is written.
Tests
Dead acquirer then a legitimate resume (conformance + DST): acquire fence on a parked execution and
never append, then resume with fenceIfParkedAt — the resume succeeds via the { fence: cur, parkSeq }
retry; with three consecutive dead acquirers racing it surfaces ResumeContendedError (retryable), never
not-suspended. Live re-drive: with no recover() call anywhere, a contended resume leaves a re-drive
row, the TimerService fires it with backoff, and the execution still completes (and gives up exactly once
via onError after maxRedriveAttempts). DST (round 3): a dead acquirer leaves token 5; resumers A and B
both read cur = 5; A acquires (6), B re-reads the park (unanswered), A’s resume commits between B’s
re-read and B’s acquire, B acquires with { fence: 6, parkSeq } — assert B is stale, B ends
not-suspended, A’s running body is never fenced out and exactly one EXECUTION_RESUMED is recorded;
run it over every interleaving of the four steps with crashes. DST: crash mid-notify (between the
fence acquire and the appendMany, and after it) over the inbox path: the redrive completes the wake with
exactly one MESSAGE_CONSUMED and one EXECUTION_RESUMED, never two deliveries and never none. Conformance: acquireFenceIf is stale
when an EXECUTION_RESUMED / EXECUTION_SUSPENDED / terminal kind exists past parkSeq even though the
token matches, and is NOT stale for EXECUTION_DENIED, OUTCOME_RECORDED, STEP_FAILED,
MESSAGE_CONSUMED; plus the Postgres append-vs-acquire race case (D1; answering append vs acquireFenceIf, on both the single-append and the appendMany JOIN path), and two unclassified-kind cases (an unknown kind stamped metadata.kernel: true is answering; an unknown kind WITHOUT the stamp, e.g. a parallel sibling’s ctx.emit after the park, is NOT stale).
Conformance case (token and parkSeq match → new token returned; token moved → stale, token unchanged; concurrent
callers → exactly one wins; an unfenced append after the park (an EXECUTION_DENIED) does NOT make the
acquire stale — the negative for the head-based design); DST: two supervisors race a park with crashes between acquire and append
and between append and read-back, assert the running parent is never fenced out and exactly one
resume is recorded; fenceIfParkedAt on an already-answered park → ResumeRefusedError
not-suspended, log untouched; sharded/tenant decorator forward and the decorator conformance kit lists
the method; retention: a finished child whose parent is parked on it is "pinned", and is
archivable again once the parent is answered or terminal.
Rejected
- Fixing it in
@loomwork/orchestrationwith more retries: the two windows are properties of an unconditional acquire, which only the store can make conditional. - Comparing the log head (the first draft): unfenced audit appends follow a park, so the head moves without the park being answered.
- Retrying on the returned token alone (round 2): a token from a stale read cannot be attributed to a dead acquirer or to a live resumer; the retry reopened window 2 until the answered-park check moved into the store (D1 (b)).
- A new LeaseStore-based “park lease”: a second coordination primitive for a question the log head already answers.
- Teaching
LogLifecycleabout children: the kernel has no child relation; the pin seam exists exactly so policy stays outside it.
Implementation (as built)
Shipped in R8k (Wave 2 lane B). EVENT_STORE_SPI 2.1.0 (bumped by W2-0) now contains the method; KERNEL_ABI and EVENT_SCHEMA are untouched. Where the code differs from the decision text above:
- SQLite does not implement
acquireFenceIf. D1 names “SQLite = oneBEGIN IMMEDIATE”, butSqliteEventStorehas never fenced (noacquireFence, no token, no append guard: single-node, the process is the failure domain), so there is nothing to compare-and-set and neither window exists. The method is optional and “a store without it keeps today’s behaviour”; adding a whole fencing subsystem to SQLite to host it would change what an append refuses there, which this ADR does not decide. The conformance cases run only where the method exists (memory, Postgres, and every decorator over them); the runtime’s fallback on SQLite is “no token”, exactly as before. The SQLite half that DOES ship is the timer-table migration (D4). - Memory (
InMemoryAsyncEventStore): one synchronous step after the tick, a binary search toparkSeq + 1and a scan of the tail withisAnsweringKind, as specified. - Postgres: one round-trip, and a park ensures its fence row.
acquireFenceIfis a single simple-protocol query of three statements (INSERT … ON CONFLICT DO NOTHINGthe fence row;SELECT … FOR UPDATE;UPDATE … WHERE fence = <expected> AND NOT EXISTS (<answering event past parkSeq>) RETURNING). A multi-statement simple query is ONE implicit transaction, and each statement in READ COMMITTED takes its own snapshot, so the scan sees every append committed before the lock was granted, which is the “separate statement” D1 asks for, withoutBEGIN/COMMITor a checked-out client (the first version took six round-trips and cost a third of a resume’s throughput). No row back means stale, and the current token is theSELECT’s. Values are inlined (a multi-statement query takes no bind parameters): the id hex-encoded, numbers checked integers, kinds the kernel’s constant names.- An append can only take
FOR SHAREon a row that exists. An execution that was never fenced has nofencesrow, so an answering append whose statement started before the acquirer created one read “no fence”, took no lock, and could land after the acquire had already looked: the conformance race case caught it under load (about one full run in six). Fix: anEXECUTION_SUSPENDEDappend (single andappendMany) also doesINSERT INTO fences … VALUES (id, 0) ON CONFLICT DO NOTHING, so the row exists, committed, before anyone can answer or acquire for that park. A token-0 row is inert for every guard (COALESCE(0, token) <= token;acquireFenceupserts to 1 as before) and costs an index probe per park once it exists. The pre-version residual is closed by a one-time backfill (review round 1, below):migrateinserts the token-0 row of every execution that is parked and unanswered right now, so a park written BEFORE this version has its row too (probe before the fix: the batch path let both an answer and the acquire succeed in 279 of 300 first-ever acquires of a pre-version park). - The append guard takes
FOR SHAREonly for a batch containing an answering kind, on BOTH paths.FOR SHARE OF fcannot be applied to the nullable side of the JOIN’sLEFT JOIN, so the locking variant reads the rows through alocked AS MATERIALIZED (… FOR SHARE OF f)CTE (which also returns the post-wait row version, where a plain read in the same statement would use the statement’s old snapshot) and joins that. - Fail-first: with the
FOR SHAREreads disabled, the single-append and theappendBatchrace cases fail in 3 of 3 runs; enabled, 11 consecutive full-suite runs passed.
- Decorators forward / route / scope it (tenant, coalescing, sharded, profiling, crypto, content,
audit-chain, the DST fault store, the crash harness);
OPTIONAL_EVENT_STORE_METHODSlists it, so a decorator that does not decide is a compile error (it was: six did not build until they did). - Runtime (D2):
RunOptions.fenceIfParkedAtonresumeandresumeAs. The park envelope comes from the events the resume already holds (else onereadRange), the fence is taken LAST (after every check that can refuse without appending, because an abandoned acquire supersedes whoever holds the old token), andassertResumableis re-run under the acquired fence against the fold in hand plus one tail read. It takes precedence overfence; on a store withoutacquireFenceIfit falls back toacquireFence; on a non-fencing store it is ignored. Three attempts, thenResumeContendedError(retryable: true,attempts, not aResumeRefusedError). - The supervisor keeps its UNFENCED first try and uses
fenceIfParkedAtonly onStaleFenceError(at most three conditional resumes perresumeParent). D2 says the loop “collapses to one attempt”; taking a fence up front would make every supervised parent fenced forever, and a fenced execution refuses an unfenced manual resume for good (TimerServiceis opt-in for the same reason). A store without the method keeps the previous two-acquisition loop verbatim. - D3:
pinParkedChildren(supervisor | () => supervisor)andChildSupervisor.parkedOnChild(@loomwork/orchestration). The edge is the child’s ownEXECUTION_STARTED.parent(R3k: only the lineage grant writes it), falling back to the supervisor’s rebuilt state; the parent must be non-terminal, parked onCHILD_DONE, with the child’s own key or a group that names it. A crypto-shredded log (PayloadShreddedError, matched through theStorageWriteErrorwrapper; as of the 2026-10-17 ADR 0039 amendment (c) by the realm-safe brandisPayloadShredded, on the error or its root cause, no longer by name) is NOT pinned (it is unreadable by design and forever; pinning it blocked its retention and compaction for good). Any other read failure is RETHROWN, so the retention run fails visibly and the log is kept (round 1: it used to answer “pinned” for every error, which the shredded case turned into a permanent pin).defineConfigcannot compose it by itself: the supervisor is built from the runtimedefineConfigreturns. The seam isapp.supervise(supervisor), which ORs the predicate intoapp.lifecycle’s pin (withdurability.retention.pinned). - D4:
TimerRecord.redrive?: { attempt }; the in-memory store, SQLite (ALTER TABLE … ADD COLUMN redrive TEXT, guarded bypragma table_info;INSERT … ON CONFLICT DO UPDATE SET redrive = COALESCE(excluded.redrive, timers.redrive)) and Postgres (ADD COLUMN IF NOT EXISTS redrive JSONBinmigrate; the same upsert) all upsert it, so a flagged row is never downgraded and a plain row gains it. The timer conformance kit gained the round-trip and upsert cases and the Postgres timer store now runs it; old-shape-table migration tests exist for both SQL stores.TimerService:redrivers/registerRedriver,maxRedriveAttempts(8),redriveBackoff { baseMs 200, capMs 30 000, random }(full jitter,random * min(cap, base * 2^attempt)). A re-drive row is NOT judged bysuspendedOn.wakeAt; each re-driver answersdone | noop | contended, the first non-noopwins,contended(or a re-driver that throws) writes rowattempt + 1BEFORE retiring this one, and atmaxRedriveAttemptsrowsonErrorreceives oneRedriveExhaustedErrorand the row is retired, leaving the execution torecover(). A node with NO re-driver registered leaves the row (the store is shared; retiring it there would be the silent loss the flag exists to prevent).superviseChildren({ redrive: { timers, service } })writes the first row (attempt 0) onResumeContendedErrorand registers its re-driver (adoptfrom the log). The flag:defineConfig({ kernel: { features: { redrive: true } } })is the first member of thekernel.featuresfamily; on, withtimersconfigured,app.redrivehands the wiring out; off (default) nothing is written and a contended resume goes toonErrorandrecover(). - Not done here: ADR 0110’s inbox notify path as a second re-driver (R2 registers it;
redriversis the seam), and the coalescing predicate for redrive rows (R2’s, ADR 0110): a redrive row is distinguishable throughdue, which is all this ADR promises.
Review round 1 (Opus review of PR #498): what changed
- P2-1, the runtime ran on a stale VM.
acquireFenceIfParkedfolded the tail forassertResumableonly;executethen appended at the OLD sequence, so an ignorable append (an unfencedEXECUTION_DENIED, an app emit) between the caller’s fold and the acquire gaveduplicate sequence, a burned token and nothing appended. It now returns the VM advanced by the tail andresume/resumeAsrunexecuteand buildanswerEventsfrom it. The acting principal is computed BEFORE the acquire (P3-1), so the fence is strictly the last await beforeexecute. - P2-2, the timer wake is acquire-if-parked. In
fencedmode on the in-process pathTimerService.fireresumes withfenceIfParkedAt(fromruntime.parkedAtSequence): no unanswered park, ornot-suspendedfrom the runtime, retires the timer as obsolete;ResumeContendedErrorleaves the row for the next poll (the existing at-least-once semantics; the re-driver is the supervisor’s, a timer wake is not its to re-derive). Not converted, by design:- the dispatcher path (
timers.dispatcher): the worker SPI’sallocateacquires its own token per dispatch (LocalWorkerRuntime.allocate->acquireFence), andWorkerRequesthas no way to carry a park sequence; converting needs aWorkerRequest/WorkerLeaseextension and a worker that resumes withfenceIfParkedAt. Named follow-up (window 2 stays open on that path, as before); recoverAbandoned: it acquires on executions that arerunning/pending, not parked, so there is no park to condition on (its own guard is the lease and the fresh-status re-check);LocalWorkerRuntime.allocate: same reason (and it also serves starts and recoveries).
- the dispatcher path (
- P2-3, the pin predicate (D3, above).
- P3-2 backfill. One-time, in
migrate, under the advisory lock, outside the L10/L21 table locks, exempt from the caller’sstatement_timeout:INSERT INTO fences … SELECTeveryEXECUTION_SUSPENDEDwith no answering event after it,ON CONFLICT DO NOTHING; a comment onfences(loom:r8k-park-fences) is the marker written in the same transaction, so it never runs twice. - P3-3, rolling upgrade is NOT safe in both directions. An OLD node’s answering append takes no
FOR SHARE, so while old and new nodes share a database the conditional acquire is only as safe as the unconditional one was (the residual window 2 remains for old writers). Upgrade order: roll EVERY node to this version first, and only then turn on anything that relies onfenceIfParkedAt(the supervisor’s conditional resume, a fencedTimerService,kernel.features.redrive). A new node against old ones degrades to the previous behaviour; it never corrupts, it just does not exclude. - P3-4.
deleteExecutionalso deletes the execution’s token-0 fence row (inert); a row with a real token is kept, as before (it fences out a zombie writer of a re-created id). Theschema.tscomment (“a row exists only once someone acquires”) now says a park writes one too. - P3-5.
ADD COLUMN IF NOT EXISTStakes ACCESS EXCLUSIVE before it checks, so every boot queued behind any open transaction ontimers. The column is now inCREATE TABLEand an older table gains it from a DO block that consultsinformation_schema.columnsfirst and waits underloom.migration_lock_timeout(a timeout fails that boot, which retries cleanly; the column is not optional, so it is not a WARNING like L10/L21). - P3-6. SQLite: the
table_infocheck and theALTERrun in oneBEGIN IMMEDIATE, and aduplicate column nameis tolerated, so two processes opening the file together both boot. - P3-7.
scheduleRedrivetakes aminWakeAt; the service passesfiredWakeAt + 1, so a zero jitter draw atwakeAt == nowcannot produce the SAME(executionId, wakeAt)key that is about to becompleted. - P3-8.
TimerStore.due(now, limit, { redrive: false })excludes re-drive rows BEFORE the limit; a node with no re-driver asks for that, so a batch of rows it cannot judge no longer starves plain wakes behind it. A store that ignores the option is still correct (the service still skips the rows). New conformance case. - P3-9. Runtime test for the LOG half of staleness (an answer past the park, token unchanged).
Review round 2: what changed
- P3-1, a contended timer wake was an acquire storm. A fenced plain wake that hit
ResumeContendedErrorleft its row due, so every poll (250 ms) took up to three more tokens, uncapped, which can keep invalidating a LIVE holder’s token. The service now backs off IN PROCESS (a per-executionnextTryAtmap, bounded to 1024 entries, same exponential full-jitter schedule and the samemaxRedriveAttemptscap as D4): no acquire is made beforenextTryAt; exhaustion reportsRedriveExhaustedErrortoonErrorONCE, retires the row and leaves the execution torecover(). A persisted re-drive row was considered and rejected for a PLAIN wake: re-drive rows are handed to the registered re-drivers (the supervisor’s), which re-derive THEIR resume and answernoopfor a timer wake, so the wake would be lost; and a node with no re-driver never sees the row. - P3-2, the backfill’s cost. The one-time park-fence backfill is a full sequential scan of
events(noevent_typeindex) under the migration advisory lock, exempt fromstatement_timeout. A partial index would not make the boot cheaper:CREATE INDEX(notCONCURRENTLY, which cannot run in the migration transaction) is itself a full scan holding a SHARE lock that blocks appends. So the boot path stays index-free and gains an opt-out, and the large-install path does the work in batches:migrate(pool, { backfillParkFences: false })(alsoPostgresEventStore/postgresStorageoptionbackfillParkFences) and the exportedbackfillParkFences(pool, { batchSize }), which builds a PARTIAL indexevents (execution_id, sequence) WHERE event_type = 'EXECUTION_SUSPENDED'withCREATE INDEX CONCURRENTLY, walks executions in id order one short statement per batch (ON CONFLICT DO NOTHING, resumable, idempotent), writes the same marker and DROPS the index. The index is not kept: after the backfill nothing scans by event type (a park lookup is per execution, served by(execution_id, sequence)), so a permanent index would only tax everyEXECUTION_SUSPENDEDappend. - Rollout note. The first boot over a large log runs the one-time backfill under the migration
lock. For a very large installation, disable it (
backfillParkFences: false), boot, and runbackfillParkFences(pool)offline; until it has run, the firstacquireFenceIfof a park that predates the upgrade can race an answering append (the pre-upgrade behaviour). A boot that fails on a lock timeout is retryable (the migration is not cached on failure): retry it, or raisemigrationLockTimeoutMs. - For 3P
TimerStoreauthors. Honourdue(now, limit, { redrive: false })by excluding re-drive rows BEFORE applyinglimit(a store that ignores it is still correct, but a batch of rows a node cannot judge starves the plain wakes behind it). - For R2 (ADR 0110). A fenced timer wake must retire its row only when ITS wait is no longer
pending, not merely when
status !== "suspended": in a multi-wait park another wait may have answered while the park (and this timer’s wait) is still the open one, or the reverse.
Tests (each failed first)
Conformance (every backend that offers the method; Postgres needs LOOM_TEST_POSTGRES_URL): next token
and old holder fenced; moved token is stale and returns the current one without changing it; eight
concurrent callers, one winner; each answering kind past the park is stale with a matching token;
answering at or before the park is not; EXECUTION_DENIED, OUTCOME_RECORDED, STEP_FAILED,
MESSAGE_CONSUMED, EXECUTION_MIGRATED, STEP_COMPLETED and an app event are not; an unknown kind
stamped metadata.kernel: true is, unstamped (or stamped "true") is not; an answering append vs the
acquire on both Postgres append paths; and every interleaving of two resumers with crashes
({A,B}^n, n <= 7, 255 schedules): at most one EXECUTION_RESUMED, the winner’s body never fenced
out, with the unconditional acquire as a negative control that does fence a body out. Runtime: dead
acquirer then resume; three dead acquirers is ResumeContendedError (not not-suspended, log
untouched); A’s resume committing between B’s re-read and B’s acquire; an unfenced EXECUTION_DENIED /
app event after the park; an answered park or a non-park sequence is not-suspended and takes no
token; fallback without the method. Supervisor: two supervisors racing a fenced parent, a dead
acquirer, a rival fencing behind every acquire (three conditional tries, then onError), the live
re-drive completing with no recover(), exhaustion reporting once, the flag off, and the pin (parked,
answered, after a restart, group members, a different key). Not run in CI: the Postgres cases skip
without a database (CI has none); they were run locally against PostgreSQL 17.
Acceptance: tools/bench-throughput fenced vs unfenced, before and after
pnpm --filter @loomwork/bench-throughput bench:fence (src/fenceIf.ts). 64 concurrent executions, 3 000
per run, median of 5 runs, loopback PostgreSQL 17 on a laptop; two independent invocations per side.
“Before” is origin/main’s PostgresEventStore.ts (the only hot-path file this ADR changes); “after” is
this PR. p-cond is a park/resume under fenceIfParkedAt; “before” it is the documented fallback to
acquireFence.
| executions/sec (p50 ms) | unfenced | fenced | park/resume, unfenced | park/resume under acquireFence | park/resume under fenceIfParkedAt |
|---|---|---|---|---|---|
| Postgres, before | 5 379 / 5 038 (11.6 / 12.4) | 4 461 / 4 399 (13.9 / 14.2) | 2 525 / 2 392 (25.2 / 26.2) | 2 313 / 2 219 (27.7 / 28.4) | 2 021 / 2 018 (fallback) |
| Postgres, after | 5 292 / 5 037 (11.9 / 12.2) | 4 478 / 4 343 (14.0 / 14.5) | 2 364 / 2 330 (26.9 / 27.3) | 2 201 / 2 180 (28.8 / 29.2) | 1 718 / 1 641 (36.7 / 37.7) |
| in-memory, after | 172 517 | 161 765 | 29 829 | 29 401 | 28 953 |
| SQLite (unchanged code; no fencing, so “fenced” = unfenced) | 7 591 | 7 670 | 3 613 | 3 649 | 3 149 |
Reading it: executions that never answer a park through the guard, and fenced ones, are within the
run-to-run spread (two invocations of the SAME build differ by up to 7%). Park/resume, which is where
the new FOR SHARE read and the park’s fence-row probe live, is about 4% lower after (2.36K vs 2.46K
unfenced, 2.19K vs 2.27K under acquireFence), which the spread does not exclude and which is the
price named in D1 (answering appends only). The conditional resume itself is the real cost, about 25%
below acquireFence (1.68K vs 2.19K): one more round-trip than the unconditional acquire (the tail
read that re-checks the park under the fence) plus the caller’s parkedAtSequence; it buys the
guarantee, and the supervisor takes it only on a stale fence. In-memory is unchanged code. The Postgres
numbers move at most a few percent where the ADR claimed the hot path would not.
Cross-reference (ADR 0116 amendment)
EventStore.appendIfUnchanged (EVENT_STORE_SPI 2.2.0) is the append-side twin of acquireFenceIf: it
takes the same Postgres fence row FOR UPDATE, and deleteExecution now takes that row too, so a
conditional append, an acquire, an answering append and a retention delete are all ordered by it. It
compares an observed token and never presents or bumps one. See ADR 0116, “Amendment — review round 2” and “Round 3”. Round 3 also states the isolation this method rests on: acquireFenceIf now starts with SET TRANSACTION ISOLATION LEVEL READ COMMITTED, because its guard needs a fresh snapshot per statement after the row lock is granted, and a server defaulting to REPEATABLE READ gave it a frozen one.
Source: docs/adr/0118-acquire-if-parked-fence.md in the loom repository.