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:
| Field | What it measures |
|---|---|
payload_bytes | Pure graph data — nodes, edges, properties. Lower bound. |
total_bytes | Payload + structural overhead (indirection, shards, stacks, slack). Aligns with RSS within a few percent on Linux. |
breakdown.compact_store_bytes | Edge adjacency — the dominant term at scale. |
breakdown.pointer_indirection_bytes | Internal NodeId ↔ external_id mapping. |
breakdown.slot_table_bytes | Slot allocator overhead for the chunk arena. |
process_rss_bytes | Kernel-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 / pair | Keeps | Use 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.
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.
| Artifact | Codec | Typical size vs raw payload |
|---|---|---|
snapshot-<ts>.bin | zstd level 1 | 3–6× smaller |
delta-<ts>-<seq>.bin | zstd level 1 | 4–8× smaller |
checkpoint-<ts>.bin | zstd level -1 | Similar 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:
| Strategy | Setting | Behaviour |
|---|---|---|
| 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. |
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 RAM | mem_limit | Primary memory_limit_bytes (~75%) | Standby memory_limit_bytes (~70%) |
|---|---|---|---|
| 16 GiB | 12g | 9_663_676_416 | 9_019_432_960 |
| 32 GiB | 24g | 19_327_352_832 | 18_038_865_920 |
| 64 GiB | 52g | 41_876_111_360 | 39_084_369_920 |
| 96 GiB | 80g | 64_424_509_440 | 60_129_542_144 |
| 300 GiB | 256g | 206_158_430_208 | 192_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:
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:
jetgraph_memory_pressure—1when RSS ≥memory_limit_bytes,0otherwise. Alert on sustained1.jetgraph_rcu_retries_total— RCU retries on the compact neighbor store. A steady climb indicates hot-row write contention — consider sharding by a higher-cardinality source or moving the edge type tois_static.jetgraph_inverse_lock_contentions_total— lock contention on the inverse index. Climbs when a destination node crosses 10 K in-neighbours and the adaptive index promotes to aBTreeSet.