Expand description
Single-lock run cancellation.
run cancel does three things under one held RunLock: refuse a run
that is already in a non-cancelled terminal state, synthesize a terminal
node.report for every still-live node, and append run.status: cancelled
once. Holding one lock for the whole operation serializes it against other
cooperating writers (those that honor the lock) so the node reads and the
node-report appends can’t interleave — which is what made the pre-refactor
CLI loop both racy and prone to over-reporting cancelled_nodes (it pushed
a node id even when the per-node append landed after another process had
already settled the node, so the reducer dropped it). Under one lock the
node we read is the node we cancel, so the reported count is honest.
This is not crash-atomic: each append_and_apply_unlocked is its own
durable append, so a crash or I/O error partway through can leave some nodes
cancelled and run.status not yet appended. Recovery is convergent — a
re-cancel of an already-Cancelled run scans the still-live stragglers
and finishes the job — not transactional rollback.
Two consistency properties beyond the single lock:
-
Enumeration and per-node liveness are from the event log, not the projection directory. The node set and each node’s current status are both replayed from
events.jsonl(the source of truth) in one streaming pass rather than scanned fromnodes/*.json. Anode.createdcan be appended+fsynced while its projection write is crash-interrupted (events.rsdocuments the log leading the projections); anodes/scan would silently drop that node, mark the runcancelled, and let a futurerebuild_projectionsresurrect it as live under aCancelledrun. Walking the log closes that window. Crucially, replayingnode.status/node.reportto derive each node’s status (rather than trustingread_node_opt) closes a second window: a non-cancel terminal event (e.g. anode.reportsuccess: true) fsynced but not yet folded leaves a stale-live projection, and a projection-derived liveness check would over-write that node with a fresh cancel that diverges on rebuild. The log-derived status settles it as already-terminal instead — the log wins. (The manifest’snode_countis also a projection written in the same interrupted fold, so it is no more authoritative thannodes/— and it carries no node ids — which is why we replay the log rather than trust the counter.) -
Each synthesized event carries a deterministic idempotency key (
run-cancel:<run_id>:node:<node_id>andrun-cancel:<run_id>:run-status). If a crash lands an append+fsync but interrupts the projection fold, the node/run still reads non-terminal, so a re-cancelwould append a second logical-cancel event (duplicating it for auditors, metrics, and rebuild). The prior cancel events (scoped by(kind, key)for this run) are captured in the same replay pass, so instead of re-appending, the loop re-folds the already-logged event viaapply_event— converging a projection the crash left non-terminal without a duplicate log line (a re-fold is a clean no-op when the projection already agrees). The whole transaction is then both non-duplicating and projection-convergent.
The cancel ledger is built by a streaming pass: for_each_event_probe
walks events.jsonl line by line, parsing only the small envelope + status
fields each line needs and materializing a full Event payload solely for
the handful of lines in this run’s run-cancel:<run_id>: key namespace (the
events the re-fold path replays). The whole log is never held in memory, so
lock-hold time and peak memory stay bounded even for a run with hundreds of
nodes and multi-KB node.report payloads.
What is still not derived from the log here: run-level liveness (the
terminal-refusal check and run_was_already_cancelled) is read from the
manifest projection, with the prior-cancel re-fold converging a crash-stranded
run.status. Deriving the run status from the log too would conflate a
crash-stranded run.status: cancelled (manifest stale, must re-fold and
report a fresh cancel) with an already-folded one, since the log is
identical in both cases — so the manifest read stays authoritative for the
run-level decision, exactly as the per-node convergence path consults
read_node_opt only to tell those two cases apart.
Structs§
- Cancel
Outcome - Outcome of a
cancel_runtransaction. Lets a thin CLI wrapper report honestly what actually changed: which live nodes it converged, which were already settled (skipped, not double-reported), and whether the run itself was already cancelled (a convergence-only no-op rather than a fresh cancel).
Functions§
- cancel_
run - Cancel a run in a single locked transaction. Acquires the run’s
RunLockonce for the whole operation, then delegates tocancel_run_unlocked. - cancel_
run_ unlocked - The locked body of
cancel_run. Thelock: &LockedRunwitness proves the caller already holds the run’s exclusiveRunLock; this is the sanctioned lock-held composition path so the manifest read, the per-node read-then-append loop, and the finalrun.statusappend all share one critical section (it callsappend_and_apply_unlocked, nevercrate::append_and_apply_event, which would deadlock by re-locking).