Documentation

Memory & Compression

Memory & Compression

JetGraph keeps the entire graph in RAM to guarantee sub-millisecond reads, so every byte is engineered. The engine uses several compression and compaction techniques — trading a few cycles for a much smaller footprint — so a single 96 GiB host can hold hundreds of millions of nodes and billions of edges.

Where Memory Goes

Use db.memoryUsage() for a live, fully-accounted breakdown, or GET /admin/memory for the same payload in JSON:

cypher
CALL db.memoryUsage() YIELD total_bytes, payload_bytes, edge_pair_count, process_rss_bytes, breakdown RETURN total_bytes, payload_bytes, edge_pair_count, process_rss_bytes, breakdown
FieldWhat it measures
payload_bytesPure graph data — nodes, edges, properties. Lower bound.
total_bytesPayload + structural overhead (indirection, shards, stacks, slack). Aligns with RSS within a few percent on Linux.
breakdown.compact_store_bytesEdge adjacency — the dominant term at scale.
breakdown.pointer_indirection_bytesInternal NodeId ↔ external_id mapping.
breakdown.slot_table_bytesSlot allocator overhead for the chunk arena.
process_rss_bytesKernel-reported process RSS (what Docker and the OOM killer see).

Per-Pair Edge Compression

Each edge type stores adjacency with a payload variant chosen at registration. Pick the smallest variant that still answers your queries:

Variant~Bytes / pairKeepsUse for
Full (default) ~52 B tx_count, approx_sum, last_seen, 21-tick activity bitmap, 8-bin histogram, optional bool flag Event edges where you query velocity, amounts, and activity windows.
Slim ~32 B Same as Full minus the per-bucket histogram. High-cardinality event edges where histograms are not needed.
Static ~16 B value + last_seen only. No inverse index maintained. Structural edges (USES_DEVICE, SIMILAR_TO) where only existence or a score matters.

At 1 B edges, choosing Static over Full for a structural edge type saves ≈ 36 GiB of RSS — and avoids maintaining an inverse index that is never queried.

cypher — register a static edge type
CALL db.registerEdgeType({ name: "USES_DEVICE", from_node_type: "CARD", to_node_type: "DEVICE", is_static: true }) YIELD edge_type_id RETURN edge_type_id

Other In-Memory Compression

🗂️

Dictionary-Encoded Strings

Recurring string values (MCCs, country codes, merchant categories) are stored once and referenced by a small integer ID.

🔢

Bit-Packed Booleans

Boolean flags (fraud, verified, active) are packed into bitmaps rather than using one byte per value.

🌲

Adaptive Inverse Index

Each destination's inverse row starts as a sorted Vec<NodeId> and is promoted to a BTreeSet only when fan-in crosses 10 K — giving cold rows tight cache layout and hot rows O(log N) writes.

📦

Chunked Rows

Adjacency rows are split into fixed-size chunks so writers clone only the affected chunk (not the whole row) — shrinking write amplification by ~170× on hot source nodes.

On-Disk Compression

All snapshots, deltas, and checkpoints are written through zstd. Hot-path writers use level 1 for low CPU overhead; the offline compactor uses the ultra-fast -1 level.

ArtifactCodecTypical size vs raw payload
snapshot-<ts>.binzstd level 13–6× smaller
delta-<ts>-<seq>.binzstd level 14–8× smaller
checkpoint-<ts>.binzstd level -1Similar to snapshot — optimised for write speed, not ratio

Disk throughput is rarely the bottleneck — delta throughput is CPU-bound on the zstd encoder, not on disk I/O. On NVMe you can ingest 30 MB deltas every 30 seconds with RSS growth matching payload growth within a few percent.

Memory-Pressure Protection

JetGraph never exceeds its configured memory budget silently. memory_limit_bytes in config.toml is the engine's RSS soft limit (set it below the container's mem_limit). When RSS crosses the limit the engine takes one of two actions based on pressure_eviction_batch_size:

StrategySettingBehaviour
Backpressure (default) pressure_eviction_batch_size = 0 Blocks new ingest with 503 / RESOURCE_EXHAUSTED, writes an emergency snapshot, and resumes automatically once RSS drops. No data is lost.
Oldest-edge eviction pressure_eviction_batch_size = 50_000 Deletes the oldest edges across all types, proportional to per-type growth, until RSS falls below the limit. Ingest is not blocked — but old history is dropped.
⚠️
Never enable pressure eviction on a standby. A replica must only delete what arrives in the primary's delta stream; otherwise the two engines diverge.

Sizing Memory

Size memory_limit_bytes from Docker mem_limit, not from memswap_limit. Swap is an emergency cushion — paging graph pages catastrophically regresses query latency. Start at 70–75 % of mem_limit and only raise after seeing stable headroom in docker stats and db.memoryUsage.

Host RAMmem_limitPrimary memory_limit_bytes (~75%)Standby memory_limit_bytes (~70%)
16 GiB12g9_663_676_4169_019_432_960
32 GiB24g19_327_352_83218_038_865_920
64 GiB52g41_876_111_36039_084_369_920
96 GiB80g64_424_509_44060_129_542_144
300 GiB256g206_158_430_208192_414_534_860

jemalloc Tuning

JetGraph ships with jemalloc as the system allocator because glibc's default fragments badly under high-churn graph writes. Recommended MALLOC_CONF in the container environment:

yaml
environment: MALLOC_CONF: "background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:10000,narenas:16"
Do not add metadata_thp:auto or percpu_arena:percpu — both inflate jemalloc's internal metadata by 1–2 GiB and obscure the engine's real RSS. The recommended string is the tested production default.

Watching Memory Metrics

Scrape GET /metrics with any Prometheus-compatible collector: