Research / Effects, recovery and replay

P15 · full · draft

What Survives kill -9: The Accumulator as a Write-Ahead Log With Offset, Settled and Residue Verdicts

shipped evidence full Effects, recovery and replay

State of the work. WAL, recovery, call-tier re-issue and model-versus-world labelling are shipped with crash tests on five tiers and a differential check against a Lean model; no overhead numbers or real-signal kill campaign yet.

This is a draft, not peer reviewed. Repository references link to revl at commit 45bec431f.

Pitch

Most undo systems promise to reverse everything; revl's crash recovery says, per effect, which ones are moot, which can be re-issued from a captured description, and which remain as residue, and it labels a recovery run against a model as modelled, not performed.

Abstract

A process that dies with kill -9 takes its in-memory undo closures with it, so the usual picture of crash recovery as "run the inverses again" is wrong. We describe the recovery design of revl, a language whose runtime already keeps an ordered accumulator of committed effects, each paired with its inverse. The accumulator is persisted as a write-ahead log (JSON Lines, one fsync per entry). A fresh process reads it and classifies only boundary state that outlives the dead process: an emission that left the process, or an acquire whose resource persists. In-process inverses are reported as moot. An inverse is re-issued only if it is reconstructible from a description (a named call with captured arguments); closure-only inverses are reported as residue and never pretended to run. Each emission ends as offset (its compensation ran), settled, or residue. Re-issue is graded by three register tiers (read, keyed, declared) and anything unknown gets one fenced at-most-once attempt. Recovery run without the real composition executes against an in-memory model and says so in the verdict and the exit status. On main today, crash tests exist for the Python tier (simulated kill) and for go, java, rust and wasm (real process exit after fsync), and a Lean model of the commit/abort decision is checked against the shipped recovery code on an enumerated corpus of 1620 logs (the model was edited once after 60 of them disagreed; see limits). We state what is not covered: power loss, and a crash between a witnessed mutation and its log entry.

Problem and claim

Write-ahead logging in databases logs "about to do X, and how to undo it", and recovery replays or reverses the log. Application-level effect systems (sagas, workflow engines, Python contextlib-style unwinding) usually either keep their undo in process memory, which is gone after a crash, or persist opaque callbacks that cannot be re-run in a new process.

The claim is narrower and more honest. After a crash only the boundary state matters (docs/crash-recovery.md:117-150). Recovery is sound to attempt for an effect only if its inverse can be rebuilt from a description, a named operation plus concrete arguments (docs/crash-recovery.md:152-190). Everything else must be reported, not guessed. The verdict is therefore three-valued per crossing (offset, settled, residue) and the report states which world it ran against.

A second claim is about the process, not the algorithm: because the accumulator was already a typed list of (effect, inverse) pairs with explicit non-invertible emissions, the WAL costs the language no new concept. The only new language-level requirement is the reconstructible-from-description rule.

Contributions

  1. The accumulator as a write-ahead log: JSON Lines, fsync per entry, fsync failure fail-closed with truncation of the failed entry, torn tail sealed before any append (docs/crash-recovery.md:14-65,193-290).
  2. The reconstructible-from-description requirement and the inverse_descriptor() split between reconstructible and closure-only inverses, with closure-only inverses surfaced as residue (docs/crash-recovery.md:152-190,736-749).
  3. Two recovery paths decided by a durable marker: roll forward when activation completed (with steady-state crossings after the marker reported as residue), roll back newest-first when it did not (docs/crash-recovery.md:403-495).
  4. A call-tier taxonomy for safe re-issue (read, keyed, declared, absent) and a single re-issue seam World.reissue, with at-most-once fences written before the attempt (docs/crash-recovery.md:292-375).
  5. "The model is not the world": every verdict carries world: model|real, modelled calls are marked [modelled, not performed], a model run never spends a fence, and exit code 3 means clean-in-the-model but nothing reconciled (docs/crash-recovery.md:495-543).
  6. Opt-in --require-settled-wal refuses to start on a WAL with unsettled emissions, and revl recover --operator-resolved SEQ logs a human acknowledgement without claiming an undo (docs/crash-recovery.md:69-93).
  7. Evidence: crash tests on five tiers and a differential check of the recovery verdict against a Lean model on 1620 enumerated logs (see Evidence on main).

Evidence on main

Tree: revl main at commit 45bec431f.

claimpath:line or teststatusnote
Accumulator persisted as a WAL, fsync per entrydocs/crash-recovery.md:193-200; backends/python/replay.py:2279 (WriteAheadLog)shippedJSON Lines; header, generation, effect, descriptor entries
fsync failure stops the crossing; failed entry truncated; opt-out loggeddocs/crash-recovery.md:25-65; tests/test_wal_fsync_fail_closed_2345.pyshippedopt-out is --allow-unsynced-wal (py) or REVL_WAL_ALLOW_UNSYNCED=1 (go, rust, java; wasm at src/revl/run_wasm.py:128)
Torn tail sealed before appendsrc/revl/wal.py:342-343; tests/test_wal_torn_tail_535.py (8 tests)shippedissue 535: unsealed append merged a fence into the torn line and re-ran an inverse
Emission start/complete/failed entries, "failure does not prove nothing reached the world"docs/crash-recovery.md:14-24; tests/test_wal_emission_start_*_2345.pyshippedpy tier; go/rust/java wrap extern emission fn the same way (docs/crash-recovery.md:36-50)
--require-settled-walsrc/revl/run.py:112,1239,2690; docs/crash-recovery.md:69-76shippedpy single-process backend only (src/revl/run.py:2694)
Operator acknowledgementdocs/crash-recovery.md:78-85; tests/test_emission_settlement_2183.py:160 (writes operator-resolved entries); tests/test_recovery_seqless_settled_2231.pyshippedtests named, not re-run
Reconstructible vs closure-only inverse descriptorsbackends/python/replay.py:2050 (inverse_descriptor), :2416shipped
Outlives-the-process policy is stated and defaults to unknownbackends/python/replay.py:1977 (_OUTLIVES_HINTS); docs/crash-recovery.md:140-150shippeda name-hint policy, see limits
Roll forward / roll back / steady-state residuesrc/revl/recovery.py:793 (_roll_forward), :297 (recover), :632 (_steady_state_residue); tests/test_crash_recovery.py (39 tests)shipped
Offset pairing for compensated emissionssrc/revl/recovery.py:2073-2210 (offset_by)shipped
Three call tiers plus absent, re-issue seamdocs/crash-recovery.md:292-375; src/revl/recovery.py:96 (_replay_tier), :1517 (_reissue_permitted); tests/test_idempotent_inverse_309.py (25 tests)shipped_replay_tier returns read, free (declared) or fenced, and does not promote keyed (src/revl/recovery.py:96-110); keyed is a forward re-issue concept. shape-proven was removed (docs/crash-recovery.md:303-316), not built
Model vs real world labelling, exit codes 0/1/3docs/crash-recovery.md:495-541shippedtests not individually opened for each exit code
Real-process crash on go, java, rust, wasmtests/test_go_crash_recovery.py (2 tests, os.Exit), tests/test_java_crash_recovery.py (4, Runtime.halt), tests/test_rust_crash_recovery.py (2, process::exit(137)), tests/test_wasm_crash_recovery.py (2, os._exit)shipped, toolchain-gatedeach skips with a reason if the toolchain is missing
Python tier crash test is simulatedtests/test_crash_recovery.py:14,214shipped"a simulated kill -9 mid-activation" writes the WAL and drops memory; no real signal
Tier-agnostic reader agrees with py writertests/test_wal_core_agrees_with_py_replay.py (6 tests)shipped
A8 commit/abort decision and at-most-once across crash cuts, as Lean theoremsformal/RevL/Theorems/A8_WalDischarge.lean:103-440shippedthe file itself marks several theorems "specification-agreement" or "definitional" (header lines 40-58)
Verdict differential-tested on 1620 logsformal/STATUS.md:66,69; formal/harness/diff_corpus.py:4972-5060shippedenumerated: 9 shapes, depth up to 2 (54 content sequences) x 3 discharge x 2 fence x 5 trailers = 1620, by arithmetic from _WAL_SHAPES, _WAL_DEPTH, _WAL_TRAILERS

Numbers that exist

Paper outline

  1. Introduction: closures die with the process; what a restart can honestly say.
  2. Background: the accumulator, inverse-or-emit (G4), compensation is not inversion.
  3. The WAL: entry format, durability rules, torn tails, fail-closed fsync.
  4. Reconstructible from description: descriptors, closure-only, the outlives policy.
  5. Recovery: roll forward, roll back, steady-state residue, offset pairing, nested crossings and placement runs.
  6. Safe re-issue: call tiers, fences, the World seam, the operator knob.
  7. Model versus world: labels, exit codes, fences never spent by a model run.
  8. Evaluation: crash tests on five tiers, the 1620-log differential against the Lean model, overhead (not yet measured), real-kill campaign (not yet run).
  9. Limits and related work.

Threats to validity and limits

Fact-check

Claims in this draft were checked against revl main at commit 45bec431f: 45 checked, 7 corrected.

Cite this draft

@misc{revlp15,
  author       = {Thomas Moussajee},
  title        = {What Survives kill -9: The Accumulator as a Write-Ahead Log With Offset, Settled and Residue Verdicts},
  year         = {2026},
  howpublished = {\url{https://inso1337.github.io/revl/research/P15.html}},
  note         = {Research draft, not peer reviewed. Written against revl commit 45bec431f. Licensed CC BY 4.0.}
}

Text licensed CC BY 4.0, author Thomas Moussajee.

← All research drafts