Loom

Type to search across the guides, specification and decision records.

↑↓ navigate ↵ open esc close Pagefind

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.redrive are the wiring) and the acceptance benchmark.
  • Date: 2026-10-05
  • Closes: R8k (the R8 review’s residual TOCTOU, named in the @loomwork/orchestration README, “Restart safety (R8)”)
  • Builds on: K9.4 (ADR 0021, acquireFence), ADR 0117 (locatePark, viewDigest on 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 RunOptions field in workflow-runtime + one optional TimerRecord field (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); contended is 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 retryable contended, not not-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 unfenced EXECUTION_DENIED after 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:

  1. 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 by recover()).
  2. Between the durable resume and the read-back a rival’s acquireFence supersedes 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:

  1. a kind in KERNEL_EVENT_KINDS is classified by its map entry;
  2. a kind NOT in KERNEL_EVENT_KINDS is an application event (ctx.emit, a ctx.stream delta, a parallel sibling’s output) and is ignorable: counting it would let a sibling that keeps emitting after the park make every acquireFenceIf stale forever, so the execution could never be resumed;
  3. 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 a contended (retried by D4), never a fenced-out body. Rule for every future kernel kind: its writer MUST stamp metadata.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/orchestration with 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 LogLifecycle about 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 = one BEGIN IMMEDIATE”, but SqliteEventStore has never fenced (no acquireFence, 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 to parkSeq + 1 and a scan of the tail with isAnsweringKind, as specified.
  • Postgres: one round-trip, and a park ensures its fence row.
    • acquireFenceIf is a single simple-protocol query of three statements (INSERT … ON CONFLICT DO NOTHING the 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, without BEGIN/COMMIT or 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 the SELECT’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 SHARE on a row that exists. An execution that was never fenced has no fences row, 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: an EXECUTION_SUSPENDED append (single and appendMany) also does INSERT 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; acquireFence upserts 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): migrate inserts 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 SHARE only for a batch containing an answering kind, on BOTH paths. FOR SHARE OF f cannot be applied to the nullable side of the JOIN’s LEFT JOIN, so the locking variant reads the rows through a locked 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 SHARE reads disabled, the single-append and the appendBatch race 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_METHODS lists it, so a decorator that does not decide is a compile error (it was: six did not build until they did).
  • Runtime (D2): RunOptions.fenceIfParkedAt on resume and resumeAs. The park envelope comes from the events the resume already holds (else one readRange), the fence is taken LAST (after every check that can refuse without appending, because an abandoned acquire supersedes whoever holds the old token), and assertResumable is re-run under the acquired fence against the fold in hand plus one tail read. It takes precedence over fence; on a store without acquireFenceIf it falls back to acquireFence; on a non-fencing store it is ignored. Three attempts, then ResumeContendedError (retryable: true, attempts, not a ResumeRefusedError).
  • The supervisor keeps its UNFENCED first try and uses fenceIfParkedAt only on StaleFenceError (at most three conditional resumes per resumeParent). 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 (TimerService is opt-in for the same reason). A store without the method keeps the previous two-acquisition loop verbatim.
  • D3: pinParkedChildren(supervisor | () => supervisor) and ChildSupervisor.parkedOnChild (@loomwork/orchestration). The edge is the child’s own EXECUTION_STARTED.parent (R3k: only the lineage grant writes it), falling back to the supervisor’s rebuilt state; the parent must be non-terminal, parked on CHILD_DONE, with the child’s own key or a group that names it. A crypto-shredded log (PayloadShreddedError, matched through the StorageWriteError wrapper; as of the 2026-10-17 ADR 0039 amendment (c) by the realm-safe brand isPayloadShredded, 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). defineConfig cannot compose it by itself: the supervisor is built from the runtime defineConfig returns. The seam is app.supervise(supervisor), which ORs the predicate into app.lifecycle’s pin (with durability.retention.pinned).
  • D4: TimerRecord.redrive?: { attempt }; the in-memory store, SQLite (ALTER TABLE … ADD COLUMN redrive TEXT, guarded by pragma table_info; INSERT … ON CONFLICT DO UPDATE SET redrive = COALESCE(excluded.redrive, timers.redrive)) and Postgres (ADD COLUMN IF NOT EXISTS redrive JSONB in migrate; 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 by suspendedOn.wakeAt; each re-driver answers done | noop | contended, the first non-noop wins, contended (or a re-driver that throws) writes row attempt + 1 BEFORE retiring this one, and at maxRedriveAttempts rows onError receives one RedriveExhaustedError and the row is retired, leaving the execution to recover(). 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) on ResumeContendedError and registers its re-driver (adopt from the log). The flag: defineConfig({ kernel: { features: { redrive: true } } }) is the first member of the kernel.features family; on, with timers configured, app.redrive hands the wiring out; off (default) nothing is written and a contended resume goes to onError and recover().
  • Not done here: ADR 0110’s inbox notify path as a second re-driver (R2 registers it; redrivers is the seam), and the coalescing predicate for redrive rows (R2’s, ADR 0110): a redrive row is distinguishable through due, 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. acquireFenceIfParked folded the tail for assertResumable only; execute then appended at the OLD sequence, so an ignorable append (an unfenced EXECUTION_DENIED, an app emit) between the caller’s fold and the acquire gave duplicate sequence, a burned token and nothing appended. It now returns the VM advanced by the tail and resume / resumeAs run execute and build answerEvents from it. The acting principal is computed BEFORE the acquire (P3-1), so the fence is strictly the last await before execute.
  • P2-2, the timer wake is acquire-if-parked. In fenced mode on the in-process path TimerService.fire resumes with fenceIfParkedAt (from runtime.parkedAtSequence): no unanswered park, or not-suspended from the runtime, retires the timer as obsolete; ResumeContendedError leaves 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’s allocate acquires its own token per dispatch (LocalWorkerRuntime.allocate -> acquireFence), and WorkerRequest has no way to carry a park sequence; converting needs a WorkerRequest / WorkerLease extension and a worker that resumes with fenceIfParkedAt. Named follow-up (window 2 stays open on that path, as before);
    • recoverAbandoned: it acquires on executions that are running / 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).
  • 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’s statement_timeout: INSERT INTO fences … SELECT every EXECUTION_SUSPENDED with no answering event after it, ON CONFLICT DO NOTHING; a comment on fences (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 on fenceIfParkedAt (the supervisor’s conditional resume, a fenced TimerService, kernel.features.redrive). A new node against old ones degrades to the previous behaviour; it never corrupts, it just does not exclude.
  • P3-4. deleteExecution also 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). The schema.ts comment (“a row exists only once someone acquires”) now says a park writes one too.
  • P3-5. ADD COLUMN IF NOT EXISTS takes ACCESS EXCLUSIVE before it checks, so every boot queued behind any open transaction on timers. The column is now in CREATE TABLE and an older table gains it from a DO block that consults information_schema.columns first and waits under loom.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_info check and the ALTER run in one BEGIN IMMEDIATE, and a duplicate column name is tolerated, so two processes opening the file together both boot.
  • P3-7. scheduleRedrive takes a minWakeAt; the service passes firedWakeAt + 1, so a zero jitter draw at wakeAt == now cannot produce the SAME (executionId, wakeAt) key that is about to be completed.
  • 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 ResumeContendedError left 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-execution nextTryAt map, bounded to 1024 entries, same exponential full-jitter schedule and the same maxRedriveAttempts cap as D4): no acquire is made before nextTryAt; exhaustion reports RedriveExhaustedError to onError ONCE, retires the row and leaves the execution to recover(). 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 answer noop for 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 (no event_type index) under the migration advisory lock, exempt from statement_timeout. A partial index would not make the boot cheaper: CREATE INDEX (not CONCURRENTLY, 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 }) (also PostgresEventStore / postgresStorage option backfillParkFences) and the exported backfillParkFences(pool, { batchSize }), which builds a PARTIAL index events (execution_id, sequence) WHERE event_type = 'EXECUTION_SUSPENDED' with CREATE 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 every EXECUTION_SUSPENDED append.
  • 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 run backfillParkFences(pool) offline; until it has run, the first acquireFenceIf of 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 raise migrationLockTimeoutMs.
  • For 3P TimerStore authors. Honour due(now, limit, { redrive: false }) by excluding re-drive rows BEFORE applying limit (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)unfencedfencedpark/resume, unfencedpark/resume under acquireFencepark/resume under fenceIfParkedAt
Postgres, before5 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, after5 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, after172 517161 76529 82929 40128 953
SQLite (unchanged code; no fencing, so “fenced” = unfenced)7 5917 6703 6133 6493 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.