04_persistence

command
v0.9.0 Latest Latest
Warning

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

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

README

Example 04 — Persistence and recovery

What it demonstrates

The full GoGraph durability path on a real directory, at a configurable, reproducible scale: build a seeded graph entirely through WAL-committed transactions, take a v2 snapshot (CSR + labels.bin + properties.bin), then drop every in-memory reference and rebuild the graph from disk with recovery.Open — proving that node and edge topology, labels, and typed properties all survive a restart.

It also demonstrates Atomicity on the abort path deliberately: just before the snapshot, two extra transactions run on the same store — a committed "survivor" publication and a "phantom" publication that is intentionally rolled back because its release declares a DEPENDS_ON its own owning package (a self-dependency the supply-chain invariant forbids). After the reopen the recovered graph must equal the pre-transaction state exactly: the survivor is present, none of the phantom's nodes/edges/labels/properties are, and the WAL holds no OpCommit marker (and not one byte) of the aborted transaction. The all-or-nothing guarantee is asserted, not assumed: a rolled-back mutation that survived would be surfaced as a fatal error.

Domain / scenario

A software-supply-chain graph, the kind a package registry maintains:

(:Package {name, language, downloads})
(:Release {coord, version, published})
(:Package)-[:PUBLISHED  {weight}]->(:Release)
(:Release)-[:DEPENDS_ON {constraint, weight}]->(:Package)

A seeded generator (math/rand from -seed) creates -packages packages, each owning exactly one release (coord = name@version), and gives every release a random number of DEPENDS_ON edges — in [-deps-min, -deps-max] — to other packages. Every node carries typed properties of three kinds (string name/coord, int64 downloads, timestamp published), and every edge carries a durable int64 weight plus, for DEPENDS_ON, a semver constraint string. Fixing -seed fixes the data shape — and therefore the recovered counts and sampled values — exactly.

Every mutation is written inside a committed transaction, so the whole graph travels through the WAL (OpAddEdgeWeighted, OpSetNodeProperty, OpSetEdgeProperty, …) and is replayed on recovery; the v2 snapshot is the checkpoint that lets recovery start from a compacted base. The store is written under a directory created with os.MkdirTemp, so its absolute path differs on every run; that path is deliberately kept out of stdout, so the deterministic report below is stable.

The atomic-rollback contrast

Two fixed publications run on the same store just before the snapshot:

  • Survivor (hotfix-security-pkg@2.0.0) — a well-formed publication that legitimately depends on an existing package. It is committed and must be present after the reopen.
  • Phantom (phantom-rogue-pkg@9.9.9) — a full publication (Package and Release nodes with labels and typed properties, a PUBLISHED edge, and a DEPENDS_ON edge) whose release declares a dependency on its own owning package. That self-dependency violates the supply-chain invariant, so the whole transaction is aborted with Tx.Rollback instead of committed.

Both publications go through the same builder and differ only in whether the caller calls Commit or Rollback, so the recovered graph is a clean A/B proof of Atomicity: the committed one survives, the rolled-back one leaves no trace — in the graph or in the WAL. The pre-transaction baseline is captured in memory right after the survivor commit and before the phantom transaction, and the recovered graph is required to match it exactly (node, edge, label, and node-property counts).

How to run

go run ./examples/04_persistence                       # small deterministic default
go run ./examples/04_persistence -packages 200000 -seed 7  # observable-scale run

Scale and flags

Flag Meaning Default Large
-packages number of Package nodes (each owns one Release) 300 200000
-deps-min minimum DEPENDS_ON out-degree per release 2 2
-deps-max maximum DEPENDS_ON out-degree per release 6 20
-batch packages processed between context-cancellation checks 50 500
-seed RNG seed (fixes the data shape) 1 any

The default is intentionally modest because persistence I/O is the slow part: it persists, snapshots and recovers in well under a second, keeping go test inside the short-layer 60 s package budget. The large run is where the persistence cost — commit throughput, on-disk bytes, recovery wall-clock — becomes observable.

Expected output

The deterministic fact lines at the default config (-packages 300 -seed 1):

config.packages=300
config.deps=[2,6]
config.batch=50
config.seed=1
nodes.packages=300
nodes.releases=300
edges.published=300
edges.depends_on=1221
survivor.committed=1
rollback.ops_attempted=13
wal.commit_markers=301
wal.phantom_frames=0
recovered.nodes=602
recovered.edges=1523
recovered.labels=2
recovered.snapshot_hit=true
recovered.sample_name=lib-server-0
recovered.sample_downloads=24941318
recovered.sample_coord=lib-server-0@3.19.1
recovered.sample_published=2021-04-18
rollback.applied_after_reopen=0
state.matches_pre_tx=1
survivor.present=1

The genesis facts (nodes.*, edges.*) count the seeded build only; the committed survivor adds its two nodes and two edges to recovered.nodes (600 → 602) and recovered.edges (1521 → 1523). The atomicity facts read: rollback.ops_attempted=13 (mutations the aborted phantom buffered), wal.commit_markers=301 (one per committed transaction — 300 genesis + 1 survivor, none for the abort), wal.phantom_frames=0 (the aborted transaction left not one byte in the WAL), rollback.applied_after_reopen=0 (none of its entities survived), state.matches_pre_tx=1 (the recovered graph equals the pre-transaction baseline), and survivor.present=1 (the committed contrast survived).

Interleaved with these, the example prints volatile telemetry lines prefixed with # , for example:

# commit.elapsed=1.18s
# commit.tx_rate=255 tx/s
# disk.wal_bytes=515.92 KiB
# disk.snapshot_bytes=194.03 KiB
# recovery.elapsed=18.5ms
# mem.heap_after=1.22 MiB

The telemetry varies per run and per machine (timings, throughput, on-disk bytes and heap), and the temp path it persists to is never printed — so only the bare fact lines are pinned by the regression test.

Evidence it collects

For the persistence / recovery subject (per the examples standard taxonomy):

  • Commit throughput# commit.tx_rate (tx/s) and # commit.edge_rate (edges/s) over the WAL write phase.
  • On-disk footprint# disk.wal_bytes, # disk.snapshot_bytes, and # disk.bytes_per_edge.
  • Recovery wall-clock# recovery.elapsed, with # recovery.wal_ops and the snapshot label/property record counts.
  • Live heap before vs after recovery# mem.heap_before, # mem.heap_after, # mem.heap_growth (each after a forced GC, so they reflect reachable bytes).
  • Atomicity on the abort pathrollback.ops_attempted, wal.commit_markers, wal.phantom_frames, rollback.applied_after_reopen, state.matches_pre_tx, and the survivor.* contrast facts: a correctness/reliability check, not a performance one, that the durability path preserves all-or-nothing when a transaction is rolled back.

When you scale -packages up, watch how recovery wall-clock and on-disk bytes grow against the recovered node/edge counts, and how the snapshot checkpoint keeps recovery.wal_ops replay bounded. The atomicity facts stay constant (0/1, 13, and packages+1) at every scale — they are invariants, not measurements.

Key APIs

  • store/wal.Open — open the write-ahead log that frames committed ops.
  • store/txn.NewStoreWithOptions / Store.Begin / Tx.Commit — apply transactions to the LPG and append them to the WAL atomically, with a typed weight codec so edge weights are durable (OpAddEdgeWeighted).
  • store/txn.Tx.Rollback — abort a buffered transaction; it discards the buffered ops without touching the WAL or the graph, so an aborted transaction writes neither op frames nor an OpCommit marker (the atomicity demo relies on exactly this).
  • store/txn.Tx.SetNodeLabel / SetEdgeLabel / SetNodeProperty / SetEdgeProperty — record labels and typed properties through the WAL.
  • store/wal.OpenReader / Reader.Frames + store/recovery.Decode — read the closed WAL back frame by frame and count txn.OpCommit markers, the structural proof that the aborted transaction wrote nothing durable.
  • graph/csr.BuildFromAdjList — freeze the live adjacency list into the CSR view the snapshot persists.
  • store/snapshot.WriteSnapshotFull — write the v2 snapshot (csr.bin + labels.bin + properties.bin + manifest.json) atomically.
  • store/recovery.OpenCtx — rebuild the graph from the snapshot plus WAL replay; the returned recovery.Result reports WALOps, SnapshotHit, SnapshotLabels, SnapshotProperties and IsClean.

Further reading

Documentation

Overview

Example 04_persistence — the full GoGraph durability path on a real directory, driven at a configurable, reproducible scale.

It builds a seeded software-supply-chain graph entirely through WAL-committed transactions, takes a v2 snapshot (CSR + labels.bin + properties.bin), drops every in-memory reference, then rebuilds the graph from disk with recovery.Open and verifies the data survived.

  1. Every mutation — nodes, labels, edges, edge labels, typed node properties, typed edge properties — is appended to the WAL and applied to the in-memory LPG inside a committed transaction, so the whole graph is durable, not just its topology.
  2. snapshot.WriteSnapshotFull persists the CSR view, labels.bin and properties.bin atomically alongside the WAL — a checkpoint of the label and typed-property state.
  3. The process "restarts": every in-memory reference is dropped and recovery.Open rebuilds the graph from the snapshot plus the WAL tail. The recovered graph is then queried back through the LPG read API to confirm counts and sample property values round-trip.
  4. Atomicity on the abort path is demonstrated deliberately: two extra transactions run on the same store just before the snapshot — a committed "survivor" publication that must persist, and a "phantom" publication whose release accidentally declares a DEPENDS_ON its own owning package (a self-dependency the supply-chain invariant forbids), so it is intentionally aborted with Tx.Rollback instead of committed. After the reopen the recovered graph must equal the pre-transaction state exactly: the survivor is present, none of the phantom's nodes, edges, labels or properties are, and the WAL holds no OpCommit marker (and not one byte) of the aborted transaction. A rolled-back mutation that survived would be an Atomicity violation and is surfaced as a fatal error, not hidden.

Model

(:Package {name, language, downloads})       // a published library
(:Release {coord, version, published})        // one version of a package
(:Package)-[:PUBLISHED {weight}]->(:Release)   // package owns its releases
(:Release)-[:DEPENDS_ON {constraint, weight}]->(:Package)

Every Package owns exactly one Release in this model (coord = name@version), and each Release declares a random number of DEPENDS_ON edges to other packages, each carrying a semver-style version constraint string and an int64 weight (the declared dependency rank). PUBLISHED and DEPENDS_ON edges both carry a durable int64 weight, so the WAL exercises OpAddEdgeWeighted and the typed-property path carries strings, int64s and a timestamp.

All node and edge properties are written inside the transaction, so they travel through the WAL (OpSetNodeProperty / OpSetEdgeProperty) and are replayed on recovery — the v2 snapshot's labels.bin / properties.bin is the checkpoint that lets recovery start from a compacted base rather than replaying the whole log.

Scale

Run with no flags, the example builds a small, deterministic default (300 packages) that persists, snapshots and recovers well under a second, so `go test` stays comfortably inside the short-layer budget. Every dimension is a flag, so the same binary scales up to a size where the persistence cost is observable:

go run ./examples/04_persistence -packages 200000 -seed 7

The deterministic data shape — recovered node, edge and label counts and the sampled property values — is reproducible for a fixed -seed; only the telemetry (lines prefixed with "# ": throughput, on-disk bytes, recovery wall-clock and live heap) varies between runs and machines. The store is written to a directory created with os.MkdirTemp whose path differs every run and is deliberately never printed, so the report stays stable.

Jump to

Keyboard shortcuts

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