17_transactional_log

command
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 14, 2026 License: MIT Imports: 22 Imported by: 0

README

Example 17 — Durable ledger: transactional log, checkpointing, and crash recovery

What it demonstrates

The full durability path of GoGraph's WAL-backed store, driven by a financial ledger: committing weighted transfers to a write-ahead log one transaction at a time, folding the log into a self-sufficient on-disk snapshot with a background checkpointer, and recovering the exact committed ledger — every transfer with its bit-exact amount — after a simulated crash. The transfer amount is the durable edge weight, so the example exercises the WAL weighted-edge op (txn.OpAddEdgeH) and the txn.WeightCodec recovery path end to end, and the recovered amounts are verified individually with lpg.Graph.EdgeWeight.

The example ships two crash demonstrations. The default is an in-process crash (fast and deterministic, pinned by the regression test): it abandons every in-memory reference and reopens the store from disk. The -real-crash flag adds a real cross-process kill -9 demonstration: it re-execs this binary as a child, commits a fixed number of fsynced transfers, then hard-kills the child with SIGKILL with a torn WAL frame in flight, and the parent proves recovery reconstructs exactly the durably-committed prefix — exercising OS-level torn-frame handling the in-process crash never touches.

Domain / scenario

A financial ledger modelled as a directed property graph: accounts are nodes, transfers are directed edges, and each transfer's amount (in integer cents) is the edge weight.

(:ACCOUNT {id})                       // id is a 24-char hex string
(:ACCOUNT)-[transfer]->(:ACCOUNT)     // weight = amount in cents

A seeded generator produces accounts accounts and transfers distinct directed transfers. The ledger is a simple directed graph: at most one transfer per ordered (src, dst) pair and no self-loops. That keeps the per-amount verification unambiguous — EdgeWeight returns the weight of the first edge for a pair, so one edge per pair means EdgeWeight(src, dst) is exactly that transfer's amount — and it makes the conservation identity exact: every transfer contributes its amount once to the source's debit total and once to the destination's credit total, so the global debit and credit totals are equal by construction and both equal the sum of all committed amounts. No redundant amount property is stored; the edge weight is the single source of truth.

Each transfer is committed as its own WAL transaction. While the transactions commit, a background checkpointer fires on a timer, snapshotting the live graph and truncating the WAL. After the last commit the example abandons every in-memory reference — modelling a process crash — and reopens the store from disk alone, verifying that every transfer survived with its exact amount.

The WAL and snapshot live under a directory created with os.MkdirTemp, so the absolute path differs on every run and is never asserted on.

The ACID coordination

The checkpointer takes its snapshot by reading the live in-memory graph. If it read concurrently with a transaction's in-memory apply it could persist a partially-applied transaction, violating Atomicity. Because this example drives the txn.Store directly, it hands the checkpointer the store's own commit serialiser via checkpoint.WithCommitSerialiser (txn.Store.RunUnderCommitLock): the checkpointer runs its snapshot-capture and WAL-truncate critical section under the store's private single-writer lock, so no transaction can be between Begin and Commit while a snapshot is taken or the WAL is truncated. Because the snapshot is written with a mapper codec (checkpoint.WithMapperCodec) it is self-sufficient, so the WAL can be truncated after each checkpoint without losing committed state.

The two crash modes
  • In-process (default). run commits the whole ledger, abandons every in-memory reference, and reopens the store from disk with recovery.OpenCtx. It is fast and byte-deterministic, so the regression test pins it — but because nothing was interrupted mid-write, it never exercises the WAL reader's torn-frame path.
  • Real cross-process kill -9 (-real-crash). runRealCrashDemo re-execs this binary (os.Args[0]) as a crash-child in a separate OS process. The child opens the store, commits -crash-committed transfers — each its own fsynced transaction, so each is durable on return — then appends a torn partial WAL frame (a header declaring a payload it never writes) to model an interrupted next commit, and hard-kills itself with SIGKILL while the store is still open. The parent waits for the child to die, confirms it was killed by SIGKILL, then reopens the data directory with recovery.OpenCtx and asserts the ledger balances to exactly the durably-committed prefix: every committed transfer present with its bit-exact amount, the interrupted transfer absent, no spurious edge conjured from the torn frame, and the benign torn tail detected (IsClean) rather than rejected as corruption. Any lost committed transfer, resurrected uncommitted transfer, or accepted torn frame is surfaced as a DURABILITY: error carrying a deterministic repro recipe — it is treated as a module durability defect, never hidden. The path needs a self-directed SIGKILL and is a clean no-op on platforms that lack it.

How to run

go run ./examples/17_transactional_log                                   # small deterministic default (in-process crash)
go run ./examples/17_transactional_log -accounts 50000 -transfers 2000000 -seed 7  # observable-scale run
go run ./examples/17_transactional_log -real-crash                       # real cross-process kill -9 crash + recovery
go run ./examples/17_transactional_log -real-crash -crash-committed 2000 -accounts 60 -seed 7  # larger crash workload

Scale and flags

Flag Meaning Default Large value
-accounts number of ACCOUNT nodes 100 50000
-transfers number of distinct directed transfer edges 600 2000000
-min-amount minimum transfer amount in cents (inclusive) 100 100
-max-amount maximum transfer amount in cents (inclusive) 1000000 1000000
-seed RNG seed (fixes the deterministic data shape) 1 any
-checkpoint-every background checkpointer age threshold (how often the WAL is folded into the snapshot) 5ms 50ms
-real-crash run the real cross-process kill -9 crash + recovery demo instead of the in-process one false true
-crash-committed real-crash demo: durably-committed transfers before the kill 50 2000

-crash-committed applies only with -real-crash; the demo sizes its plan to crash-committed + 1 transfers (the extra one is the deterministic in-flight transfer the crash interrupts) and requires that count not to exceed accounts*(accounts-1). The internal -crash-child-dir flag is set only by the parent when it re-execs the child; it is not meant to be passed by hand.

transfers must not exceed accounts*(accounts-1): a simple directed graph with no self-loops has at most that many distinct ordered pairs. The default commits one fsynced transaction per transfer, so it stays comfortably under the 60 s per-package short-test budget while still exercising the WAL, the background checkpointer (dozens of checkpoints), and recovery for real.

Expected output

The deterministic facts (bare lines) are reproducible for a fixed -seed. The telemetry lines (prefixed # ) — commit throughput, the checkpoint count and folded bytes, the snapshot footprint, and the recovery wall-clock — vary per run and per machine and are never asserted. A representative run at the default config:

config.accounts=100
config.transfers=600
config.amount=[100,1000000]
config.seed=1
nodes.accounts=100
edges.transfers=600
ledger.amount_sum=288773518
recovered.accounts=100
recovered.transfers=600
recovered.amount_sum=288773518
ledger.debit_sum=288773518
ledger.credit_sum=288773518
ledger.conserved=true
# commit.elapsed=3.5s             # telemetry — varies, never pinned
# commit.tx_rate=169 tx/s         # telemetry — varies, never pinned
# checkpoint.count=33             # telemetry — varies, never pinned
# checkpoint.wal_bytes_folded=209.50 KiB   # telemetry — varies, never pinned
# checkpoint.snapshot_bytes=27.18 KiB      # telemetry — varies, never pinned
# recovery.elapsed=359µs          # telemetry — varies, never pinned
# recovery.snapshot_hit=true      # telemetry — varies, never pinned
# recovery.wal_ops=45             # telemetry — varies, never pinned

The deterministic invariants the regression test pins are the recovered counts (recovered.accounts, recovered.transfers), the bit-exact recovered amount sum (recovered.amount_sum == ledger.amount_sum), and the conservation identity (ledger.debit_sum == ledger.credit_sum == ledger.amount_sum, surfaced as ledger.conserved=true). run additionally verifies every individual transfer with EdgeWeight(src, dst) before reporting these, so a single corrupted amount fails the run. The checkpoint stats, the recovered WAL-op count, and the temp path are volatile and are not asserted.

Real cross-process crash (-real-crash)

The -real-crash demonstration reports a different set of facts — the durability outcome across a real SIGKILL. A representative run (-real-crash -crash-committed 50):

config.accounts=100
config.seed=1
crash.committed_before_kill=50
recovery.transfers_replayed=50
recovery.torn_tail_detected=1
recovery.balance_conserved=1
recovery.is_clean=1
# crash.child_termination=signal:killed   # telemetry — how the child died
# recovery.elapsed=602µs                   # telemetry — varies, never pinned
# recovery.wal_bytes_replayed=17.72 KiB    # telemetry — the durable WAL prefix
# recovery.wal_ops=250                      # telemetry — ops replayed from the WAL
# recovery.snapshot_hit=false              # telemetry — WAL-only, no checkpoint

The deterministic invariants here are recovery.transfers_replayed equalling crash.committed_before_kill (every fsynced transfer recovered, no more), recovery.torn_tail_detected=1 (the interrupted frame was seen), and both recovery.balance_conserved=1 and recovery.is_clean=1. The child's termination, the recovery wall-clock, the replayed WAL byte count, and the WAL-op count are telemetry.

Evidence it collects

This is a persistence/recovery subject, so it reports the evidence from that row of the taxonomy:

  • Commit throughput# commit.tx_rate (transactions per second; one fsynced transaction per transfer, so this measures the durable write path).
  • Checkpoint fold stats# checkpoint.count (how many times the WAL was folded), # checkpoint.wal_bytes_folded (WAL bytes truncated into snapshots), and # checkpoint.snapshot_bytes (the on-disk snapshot footprint).
  • Recovery wall-clock# recovery.elapsed, plus # recovery.snapshot_hit and # recovery.wal_ops to show how much state came from the snapshot vs the WAL tail.

When you scale it up (-accounts 50000 -transfers 2000000), watch how the checkpoint count and folded-bytes grow with the commit stream, how the snapshot footprint tracks the live graph rather than the full WAL, and how recovery time is dominated by the WAL tail since the previous checkpoint rather than by the whole history.

The -real-crash mode adds durability-under-crash evidence: it recovers the WAL after a genuine SIGKILL and reports the recovery wall-clock (# recovery.elapsed) and the durable WAL byte count replayed (# recovery.wal_bytes_replayed), alongside the deterministic all-or-nothing facts. Scale -crash-committed up to see recovery time and replayed bytes grow linearly with the durable prefix.

Key APIs

  • store/wal.Open — open the write-ahead log file that makes commits durable.
  • store/txn.NewStoreWithOptions (txn.Options with NewStringCodec + NewInt64WeightCodec) / Store.Begin / Tx.AddEdge / Tx.Commit — the transactional write path; AddEdge(src, dst, amount) records the amount as the durable edge weight via an OpAddEdgeH frame, and each commit fsyncs its WAL frames then applies atomically to the in-memory graph.
  • store/checkpoint.New / WithCommitSerialiser / WithMapperCodec / Checkpointer.Start / Stop / Stats — the background checkpointer; WithCommitSerialiser(store.RunUnderCommitLock) serialises snapshots against commits under the store's own commit lock.
  • store/recovery.OpenCtx — rebuild the graph after a crash from the snapshot plus any WAL tail; reports SnapshotHit, WALOps, WALTailOffset (the durable prefix boundary), TailErr, and IsClean (true for a nil or benign torn tail; false — with a matching function error — for genuine corruption).
  • graph/lpg.Graph.EdgeWeight / AdjList.HasEdge / AdjList.Size / LiveOrder — verify the recovered transfers, their exact amounts, the recovered edge count, and the account count.
  • store/wal.Magic / wal.CurrentVersion — the frame-header constants the -real-crash child uses to append a deliberately torn partial frame (see appendTornFrame).
  • os/exec.CommandContext + os.Args[0] (-real-crash) — re-exec this binary as a crash-child that commits, tears, and SIGKILLs itself; syscall.Kill(os.Getpid(), SIGKILL) delivers the kill (see selfkill_unix.go).

Further reading

Documentation

Overview

Example 17_transactional_log — a durable financial ledger: a WAL-backed store with a background checkpointer that folds the log into a self-sufficient on-disk snapshot, plus recovery after a simulated crash.

The flow: open a write-ahead log, build a txn.Store over it with a node codec and an int64 weight codec, commit a seeded stream of ledger transfers — one transaction each — while a background checkpointer folds the WAL tail into a self-sufficient snapshot and truncates the log, then simulate a crash (every in-memory reference is abandoned) and recover the ledger from disk alone, verifying that every committed transfer comes back with its exact amount.

Model

(:ACCOUNT {id})                              // id is a 24-char hex string
(:ACCOUNT)-[transfer]->(:ACCOUNT)            // weight = amount in cents

A transfer is a directed edge whose WEIGHT carries the transfer amount in minor currency units (integer cents). The amount is therefore part of the durable record: it is committed via txn.Tx.AddEdge (which a weight-codec store emits as an txn.OpAddEdgeH frame carrying the int64 amount through the txn.WeightCodec) and read back after recovery with lpg.Graph.EdgeWeight. No redundant "amount" property is stored — the edge weight is the single source of truth, so the example exercises the WAL weighted-edge op and the weight-codec recovery path end to end.

The ledger is a SIMPLE directed graph: at most one transfer per ordered (src, dst) pair and no self-loops. That keeps the per-amount verification unambiguous — EdgeWeight returns the weight of the first edge for a pair, so one edge per pair means EdgeWeight(src, dst) is exactly that transfer's amount — and it makes the conservation identity exact: every transfer contributes its amount once to the source's debit total and once to the destination's credit total, so the global debit and credit totals are equal by construction and both equal the sum of all committed amounts.

ACID coordination

The checkpointer snapshots the live graph; if it read concurrently with a transaction's in-memory apply it could persist a partially-applied transaction and violate Atomicity. This example drives the txn.Store directly, so it hands the checkpointer the store's own commit serialiser via checkpoint.WithCommitSerialiser (txn.Store.RunUnderCommitLock): the checkpointer runs its snapshot-capture and WAL-truncate critical section under the store's private single-writer lock, so no transaction can be between Begin and Commit while a snapshot is taken or the WAL is truncated. checkpoint.WithMapperCodec persists the NodeID->key mapper so the string-keyed snapshot is self-sufficient and the WAL can be safely truncated after each checkpoint.

Crash modes

The example ships two crash demonstrations. The default is an in-process crash: run drives the commit stream, abandons every in-memory reference, and reopens the store from disk. It is fast and deterministic, so the regression test pins it — but it never exercises OS-level torn-frame handling, because nothing was interrupted mid-write. The -real-crash flag instead re-execs this binary as a child process (runRealCrashDemo): the child commits -crash-committed transfers, each fsynced, appends a torn partial WAL frame modelling an interrupted next commit, and hard-kills itself with SIGKILL; the parent then reopens the data directory with recovery.OpenCtx and proves the ledger balances to exactly the durably-committed prefix — no committed transfer lost, no uncommitted or torn frame accepted. The real-crash path needs SIGKILL and degrades to a no-op on platforms that lack it.

Scale

The default is a small, deterministic ledger (200 accounts, 2000 transfers) that builds and recovers well under a second, so the regression test stays fast. Every dimension is a flag, so the same binary scales up to where the persistence stack is worth observing:

go run ./examples/17_transactional_log -accounts 50000 -transfers 2000000 -seed 7

The deterministic facts (counts, the recovered-amount sum, the conservation totals) are reproducible for a fixed -seed; only the telemetry (lines prefixed with "# ") and the temp directory path vary between runs and machines.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL