Skip to content

ADR 0001 — Storage model: RAM-resident with durable logging

  • Status: Accepted
  • Date: 2026-08-13
  • Card: HUB-47
  • Supersedes: nothing
  • Affects: HUB-34, HUB-36, HUB-37, HUB-39, HUB-48, HUB-49, HUB-51, HUB-52, HUB-55

Context

internal/storage/disk.Store is named "disk" but is not disk-resident. The complete graph — every vertex, every edge, and both adjacency indexes — lives in Go maps in process memory. Disk is used only for durability: a write-ahead log for crash recovery and a periodic full-graph JSON snapshot.

Two consequences follow, and neither is currently written down anywhere:

  1. The maximum dataset Crystal can hold is bounded by RAM, not by disk.
  2. Every start pays a full load: read the snapshot, replay the WAL, rebuild the adjacency index. Start time grows linearly with the graph.

This is a fork in the road. A paged or LSM-backed engine — where the working set is cached and cold data stays on disk — has a different answer for indexing, checkpointing, pagination, recovery and query execution. Roughly half the board depends on which branch is taken, so the decision is cheapest to make now and most expensive to make after those cards are built.

Decision

Crystal stays a RAM-resident engine with durable disk logging. Working set == whole graph. Disk is a durability device, not a storage tier.

The core.Storage interface stays the seam that would make a paged backend possible later, but no work is scheduled against that possibility, and no card on this board is designed to accommodate it speculatively.

What this commits us to

  • The graph must fit in RAM, with headroom for query and traversal working sets.
  • Checkpointing is a full-graph operation. Making it cheap means making it concurrent (HUB-37), not making it incremental at the page level.
  • Indexes (HUB-48, HUB-49) are in-memory structures rebuilt at load, not persisted B-trees. They cost RAM on top of the graph itself.
  • Pagination (HUB-55) is a slice over an in-memory collection. It bounds response size, not scan cost — a filtered query still visits every candidate.
  • Snapshot encoding (HUB-52) is optimised for load time and file size, not for random access. It is never read partially, so it does not need page structure.

What this rules out for now

  • Datasets larger than memory.
  • Sub-second cold start on large graphs — start is O(graph).
  • Buffer-pool / page-cache work, mmap-backed stores, LSM compaction.

Capacity envelope

Measured against the repository's own benchmark fixture (memory backend, Go 1.26, linux/amd64), by taking runtime.HeapAlloc after a forced GC before the load, after 100k vertices, and again after 1M edges. Property density is the benchmark's, which is realistic rather than minimal: vertices carry 4 properties and 0–2 labels, edges carry 2 properties plus a weight.

Measurement Value
Heap after 100,000 vertices 74.9 MB (Δ 74.5 MB)
Heap after 1,000,000 further edges 700.7 MB (Δ 625.9 MB)
Marginal cost per vertex (entity + properties + labels + adjacency slots) 745 bytes
Marginal cost per edge (entity + properties + 2 adjacency entries) 626 bytes
Live heap for the full 100k/1M graph 668 MB
Process RSS for the same graph 1.5 GB

Two things to read off that table. First, the planning constants:

live heap ≈ 745 B × |V| + 626 B × |E|

Second, the 668 MB → 1.5 GB gap. That is Go's garbage collector, which needs roughly twice the live heap to run without thrashing. Budget RAM at 2× the live heap; the table below already does.

| RAM available to Crystal | Live-heap budget | Graph at benchmark density (|E| ≈ 10·|V|) | |---|---|---| | 1 GB | 0.5 GB | ~76k vertices / ~760k edges | | 4 GB | 2 GB | ~300k vertices / ~3M edges | | 16 GB | 8 GB | ~1.2M vertices / ~12M edges | | 64 GB | 32 GB | ~4.9M vertices / ~49M edges |

Structure is the cheap part: an edge costs 626 bytes carrying only two properties, and a bare edge is a small fraction of that. The dominant term in any real deployment is property payload, so a graph of sparsely-propertied entities lands well under these figures and one carrying text blobs lands well over. Re-measure with your own density rather than trusting the ratio.

The supported envelope for Crystal v1 is ~1M vertices / ~10M edges on a 16 GB host, scaling linearly with RAM. Past that the engine still runs, but nothing on this board is designed or benchmarked for it.

Rationale

The target workloads are human-authored graphs. Crystal exists for investigation mapping (Maltego-shaped) and narrative design (Articy-shaped). Both are bounded by what people and their tooling produce — 10^4 to 10^7 elements. Neither is a web-scale ingest pipeline. The envelope above covers the intended use with room to spare.

Embedded-first argues for simplicity, not for paging. The stated goal is "SQLite for graphs" in packaging terms: one binary, no external dependencies, no daemon to operate. SQLite itself is paged, but SQLite is also solving for datasets far past RAM on 1990s hardware. The packaging property is what Crystal needs; the paging property is what it would pay for without needing.

RAM-residency is what makes the traversal story good. Multi-hop traversal is the primary operation and is pointer-chasing by nature. Served from memory it is predictable; served from a page cache it inherits every miss. Giving that up is a large cost against a benefit the target workloads do not collect.

A paged engine is a different project. Buffer pool, page layout, WAL against pages rather than entities, compaction, recovery against partially-written pages — that is the bulk of the remaining board, spent on capacity nobody has asked for. The board's actual problems are durability correctness, DoS bounds, and missing security; those are worth more per unit of work.

Consequences

Immediate

  • docs/architecture.md states the RAM-residency limit and the envelope explicitly.
  • Cards that assumed a paged model are re-scoped (see below).
  • Stats() should eventually expose an estimated-memory figure so operators can see where they sit in the envelope. Tracked separately, not in this ADR.

Card re-scoping

Card Re-scoped to
HUB-34 Snapshot integrity for a full-file rewrite: fsync + atomic rename + version header. No page checksums.
HUB-36 Checkpoint triggers bound WAL size and recovery time. They do not bound memory — memory is bounded by the graph itself.
HUB-37 Concurrent full-graph snapshot (copy the view, serialise unlocked). Not incremental page flushing.
HUB-39 Snapshot isolation over in-memory versions. Not an on-disk MVCC log.
HUB-48/49 In-memory indexes, rebuilt on load. Not persisted B-trees.
HUB-51 Read path returns shared immutable entities. This is the highest-value memory win available and only makes sense in a RAM-resident model.
HUB-52 Binary snapshot encoding targets load time and size. Not a page format.
HUB-55 Pagination bounds response size; scan cost stays O(candidates). Documented as such.

Revisit when

Any one of these invalidates the decision and reopens this ADR:

  • A real workload needs more than ~10M vertices or ~50M edges.
  • Cold-start time on a real dataset becomes an operational complaint.
  • Crystal grows a multi-tenant story where many graphs are resident but few are hot (HUB-70 is the early warning — many namespaces on one server multiplies resident cost by the number of namespaces).