{"slug": "slater-low-memory-graphdb-designed-for-read-heavy-graphs", "title": "Slater – Low-memory graphdb designed for read-heavy graphs", "summary": "Hikari Systems released Slater v0.24.1, a low-memory graph database designed for read-heavy graphs that can serve hundreds of millions of nodes and billions of edges from a few hundred MB of RAM by paging from disk instead of holding the graph resident. Slater uses a log-structured-merge writable layer over an immutable core, supports the Bolt protocol for compatibility with Neo4j drivers, and includes disk-native vector search, making it suitable for knowledge graphs, recommendation systems, and identity graphs.", "body_md": "**Current version: v0.24.1** —\n\n[all releases](https://github.com/Hikari-Systems/slater/releases).\n\nIn one line:Slater servesgraphs that don't fit in memory— hundreds of millions of nodes and billions of edges in low hundreds of MB of RAM — over standardBolt, so any neo4j driver just works, with disk-native vector search sitting next to the graph, and it takeslive, durable writeswithout giving that up. Resident memory is set by a cache budget you choose,notby the size of the graph.\n\n**Shortcuts**\n\n|\n\n[Reads and writes](#reads-and-writes)[What you get](#what-you-get)[Features](#features)[How it works](#how-it-works)[The writable layer](#the-writable-layer)[Storage backends](#storage-backends-filesystem--s3--gcs)[Mounts](#mounts)[ACL](#acl)[Health check](#health-check)[Worked example](#worked-example)[Development](#development)[Performance](#performance)[License](#license)[📖 Full manual](/Hikari-Systems/slater/blob/main/docs/manual/README.md)A **graph database** stores data as *things* (nodes) and the *relationships between them* (edges), with the relationships as first-class citizens. That's what you want when your questions are about connections rather than rows — \"who's within three hops of this account?\", \"what's the full dependency chain behind this build?\", \"which accounts share a device, an address, and a card?\" — the queries that become a swamp of recursive joins in SQL but fall out naturally in a graph.\n\n**The most common complaint about graph databases is that they don't scale past what you can hold in RAM.** Many of them (eg neo4j, Memgraph, FalkorDB, etc) keep the whole graph resident: a 40 GB graph wants 40 GB of memory — *per instance*. Want a replica per region, per tenant, or per pod? Multiply the bill. And past a certain size they simply won't load: eg the 90 million node / 1.5B-edge Wikidata graph needs ~64–128 GiB resident, so the in-memory engines can't open it at all.\n\nSlater is the rebuttal. It serves that same 90M-node graph from **a few hundred MB of RAM**, because it pages the graph from an on-disk image on demand instead of holding it resident — so the graph size and the memory bill are decoupled.\n\nSlater takes the opposite approach. You compile the graph once, offline, into a content-addressed on-disk image with `slater-build`\n\n; then any number of Slater servers serve it over **Bolt** (so your existing neo4j drivers just work) while holding only a fixed cache budget in memory. **A 4 GB graph and a 400 GB graph cost the same RAM to serve** — you fan out cheap, stateless read replicas and let the store, not the heap, hold the graph.\n\nThat makes it a natural fit for knowledge graphs behind RAG, recommendation and identity graphs, dependency graphs — anything large and connected you want to query cheaply and often. Disk-native vector search lives right next to the graph, so the same engine is the retrieval layer for embeddings too.\n\nSlater is a **read-write** graph database. You don't rebuild the whole image to correct one property, add a node, or retract an edge — you write the change directly, over Bolt, and it lands durably. The trick is that writes never tax the read path.\n\nWrites accumulate in a **log-structured-merge (LSM) layer over the immutable core**: a write-ahead log and an in-memory table, spilling to immutable delta segments, folded back into a fresh core by a periodic **consolidation**. What that buys you:\n\n**Reads over an unwritten graph cost exactly what they did before.** An empty delta is a single predictable branch, not a merge — the read path is byte-identical whether or not the writable layer is on.**The read cost of a write scales with the size of the delta, not the size of the graph.** Whole-graph answers —`count(*)`\n\n, the label and relationship-type marginals — stay*metadata reads*even with writes outstanding: the delta keeps its own counters, so a`count(*)`\n\nover a 91.6M-node core with half a million pending writes still answers in tens of milliseconds without touching a single block.**Acknowledged means durable.** A single writer drains the queue and returns`SUCCESS`\n\nonly after the`fsync`\n\nthat covers the write. Group your writes and they're cheap — a write-`UNWIND`\n\ncommits one`fsync`\n\nper batch rather than per row.**Business-key writes, in either dialect.**`MERGE`\n\n/`MATCH … SET`\n\n/`DELETE`\n\n(and`CREATE`\n\n/`REMOVE`\n\n, detach delete, relationship writes) keyed on a node's identity property — or the equivalent ISO GQL data-modifying statements (`INSERT`\n\n/`SET`\n\n/`REMOVE`\n\n/`DELETE`\n\n), which lower onto the same path. Correct, insert, upsert and retract, over nodes and edges, addressed the way your data already is.\n\nThe writable layer is opt-in (`delta.enabled`\n\n); with it off, Slater serves the pure immutable core and refuses writes. See [The writable layer](#the-writable-layer) for the full model.\n\nOn the name.Slater is named after the CIA agent inArcher(a great show) who insists on going by a single name — \"Just… Slater\" — and one of my favourite characters in it. See the[character wiki page].\n\n**RAM set by your cache budget, not your graph size**— fan out as many read replicas as you like; the graph never has to fit in memory.** A drop-in for the graph**— speaks Bolt, so any standard neo4j driver (JS, Python, Go…) works unchanged. It's Cypher (plus a slice of ISO GQL, reads and writes); nothing new to learn.**Live, durable writes**— an opt-in LSM layer over the immutable core: business-key`MERGE`\n\n/`SET`\n\n/`DELETE`\n\nover nodes and edges, group-committed and`fsync`\n\n-durable, folded back into a fresh core by consolidation. Reads don't pay for it.**Deployment by file swap**— build a new content-hashed*generation*offline, atomically flip the`current`\n\npointer, and servers pick it up. Every block is checksummed, so a half-copied image is refused rather than served.**Vector search built in**— disk-native approximate-nearest-neighbour (cosine, L2, or dot KNN) sits right next to your graph, for when this is the retrieval layer behind a RAG pipeline, and embeddings are writable in place — no offline rebuild to add or change a vector.**Locked down by design**— read and write grants are independent, plus optional at-rest encryption, TLS Bolt, argon2id-hashed ACLs, and a read-only container rootfs for read replicas.\n\n| Feature | What it means for you |\n|---|---|\nBounded, predictable memory |\nResident memory is capped by three cache budgets you set — it does not grow with graph size; you tune the performance/RAM trade-off instead of provisioning for the whole graph. A jemalloc allocator with background purge returns freed memory to the OS after heavy query bursts, so resident size falls back toward its idle floor rather than staying pinned at the post-burst high-water mark. |\nMulti-tenant out of the box |\nOne server hosts many graphs with per-user read grants — multi-database isolation that most graph DBs reserve for a paid/enterprise tier. |\nEncryption at rest & in transit |\nPer-block XChaCha20-Poly1305 sealing (the key is never written to disk) plus optional TLS (`bolt+s://` ). GDPR-friendly by construction. |\nTiny, dependency-light install |\nA small stripped binary on a distroless glibc base (no shell/apt) — the multi-arch (amd64/arm64) image pulls at ~22 MB, or ~12 MB for the server-only `slater:latest-lite` tag; pure-Rust TLS, no OpenSSL. Pull and run. |\nBuilt for periodic publish |\nBuild a graph offline, serve it immutable, then atomically swap in a new version with zero downtime — ideal for data-warehouse / scheduled-refresh workloads. |\nRugged under load |\nThe server and offline builder both compile with `#![forbid(unsafe_code)]` — the engine's only `unsafe` lives in the audited jemalloc allocator crate. The core is immutable, so reads take no locks and never wait on a writer; a single writer serialises mutations behind the write path alone. No GC pauses, no data races. One bad query can't take the server down. |\nWorks with your neo4j tools |\nSpeaks Bolt 5.4 / 4.4 / 4.1 — use the standard neo4j drivers (JS, Python, Go, Java…), `cypher-shell` , or graph browsers unchanged. |\nRich Cypher query surface |\nA broad read surface: `MATCH` /`WHERE` /`WITH` /`UNION` , `CALL {…}` subqueries, 70+ functions & aggregations, temporal & geospatial values, and regex. |\nLive, durable writes |\nAn opt-in single-writer LSM layer over the immutable core (`delta.enabled` ): business-key `MERGE` / `SET` / `DELETE` / `CREATE` / `REMOVE` over nodes and relationships, batched write-`UNWIND` (one `fsync` per batch), and `CALL slater.consolidate()` — group-committed, `fsync` -durable, and folded back into a fresh core by consolidation. The read path is byte-identical when the delta is empty. |\nISO GQL, read and write |\nSpeaks a subset of ISO GQL (ISO/IEC 39075) over the same Bolt connection — quantified paths, path restrictors, shortest-path selectors, label/type boolean expressions, `FOR` , `CAST` , an optional `GQL` /`CYPHER` dialect prefix — and, with the writable layer on, GQL's data-modifying statements (`INSERT` / `SET` / `REMOVE` / `[DETACH] DELETE` ) lower onto the same durable write path. Cypher and GQL, reads and writes, in one engine. |\nVectors + graph in one engine |\nDisk-native ANN vector search (Vamana + PQ; cosine / L2 / dot) for embeddings/RAG, plus graph algorithms (PageRank, BFS, betweenness, WCC…) — bounded memory even with millions of vectors. Embeddings are writable (a FreshDiskANN-style write ladder): insert / update / delete a vector, KNN-visible at once, folded into the base without a rebuild. |\nSafe on network storage |\nEvery file is BLAKE3 content-hashed and verified on open; torn or half-copied images are refused, not served. Designed for NFS/remote volumes (no mmap surprises). |\nPluggable storage backends |\nServe the same generation format from a local filesystem, an S3 (S3-compatible) bucket, or a Google Cloud Storage bucket — publish once, fan out to stateless replicas — with an optional local-SSD cache tier in front of the object store. See\n|\n\nTwo binaries make up the workspace:\n\n| Binary | Role |\n|---|---|\n`slater` |\nThe online Bolt server (the container ENTRYPOINT): serves reads and, with `delta.enabled` , the single-writer durable write path. |\n`slater-build` |\nThe offline compiler: turns a primitive-Cypher dump into an immutable, content-hashed generation directory. |\n\nSlater splits *bulk building* from *serving*: `slater-build`\n\ndoes the heavy lifting\noffline — ingesting your data and compiling it into an immutable generation — so a\ncold graph is never assembled on the serving hot path. Within the server, the read\nsurface answers a broad Cypher slice — pattern matching, `WITH`\n\n/`UNION`\n\n/`CALL {…}`\n\nsubqueries, 70+ scalar & aggregate functions, temporal & geospatial values, graph\nalgorithms (`algo.*`\n\n), and disk-native vector KNN (`db.idx.vector.queryNodes`\n\n) —\nwhile the writable layer's delta overlay sits *below* that surface and is zero-cost\nwhen empty, so reads never carry the write-side machinery. You can update a graph\ntwo ways: write to it live over Bolt (see [The writable layer](#the-writable-layer)),\nor build a new generation offline and atomically swap the `current`\n\npointer, which\nthe running server picks up via its generation guard (see\n[Generation guard](#generation-guard)).\n\nThe complete **user manual** lives in —\na feature-by-feature guide that explains, for every capability, what it is, why it\nexists, and how to use it, with worked examples you can run against a bundled\nsample graph. Start there for anything beyond this overview.\n\n`docs/manual/`\n\n**New here?**[Quickstart](/Hikari-Systems/slater/blob/main/docs/manual/02-quickstart.md)builds and serves a graph in five steps.** Writing queries?**[Querying](/Hikari-Systems/slater/blob/main/docs/manual/07-querying.md),[Functions & expressions](/Hikari-Systems/slater/blob/main/docs/manual/08-functions-and-expressions.md),[Procedures & algorithms](/Hikari-Systems/slater/blob/main/docs/manual/09-procedures-and-algorithms.md),[Vector search](/Hikari-Systems/slater/blob/main/docs/manual/10-vector-search.md),[Writing data](/Hikari-Systems/slater/blob/main/docs/manual/11-writing-data.md).**Building graphs?**[Building graphs](/Hikari-Systems/slater/blob/main/docs/manual/05-building-graphs.md)and the[Build CLI reference](/Hikari-Systems/slater/blob/main/docs/manual/06-build-cli-reference.md).**Operating Slater?**[Deployment](/Hikari-Systems/slater/blob/main/docs/manual/13-deployment.md),[Storage](/Hikari-Systems/slater/blob/main/docs/manual/12-storage.md),[Configuration reference](/Hikari-Systems/slater/blob/main/docs/manual/14-configuration-reference.md),[Security](/Hikari-Systems/slater/blob/main/docs/manual/15-security.md),[Performance tuning](/Hikari-Systems/slater/blob/main/docs/manual/16-performance-tuning.md).\n\nSlater is designed to be run as a **Docker deployment** — that's the expected way\nto use it. Prebuilt multi-arch images (`linux/amd64`\n\n+ `linux/arm64`\n\n) are\npublished to **Docker Hub** at\n,\ntagged\n\n`hikarisystems/slater`\n\n`:latest`\n\nand `:vX.Y.Z`\n\non every release:\n\n```\ndocker pull hikarisystems/slater:latest\n```\n\nA Docker-command-only usage, configuration, and operations guide lives in\n[ DOCKERHUB.md](/Hikari-Systems/slater/blob/main/DOCKERHUB.md) (and is mirrored to the Docker Hub overview page) —\n\n**start there if you're deploying.** In short:\n\n```\n# Build a graph generation with the offline writer:\ndocker run --rm -v slater-data:/data -v \"$PWD/dumps:/dumps:ro\" \\\n  --entrypoint /app/slater-build hikarisystems/slater:latest \\\n  --input /dumps/people.cypher --graph people --data-dir /data\n\n# Serve it over Bolt on 7687 (read-only unless `delta.enabled`):\ndocker run -d --name slater -p 7687:7687 \\\n  -v slater-data:/data:ro -v \"$PWD/acl.json:/config/acl.json:ro\" \\\n  hikarisystems/slater:latest\n```\n\nTo build the image locally instead (e.g. for development):\n\n```\n# Build the image (both binaries).\ndocker compose build\n\n# Serve (expects generations under the slater-data volume / your /data mount).\ndocker compose up slater\n\n# Build a generation with the offline writer (profile `build`):\ndocker compose run --rm builder \\\n  --input /dumps/people.cypher --graph people --data-dir /data\n```\n\nThe builder stage installs `cmake`\n\n, `clang`\n\nand `libclang-dev`\n\nfor the rustls\n`aws-lc-rs`\n\nbackend; `git`\n\n(already in the base image) is required for the\n`hs-utils`\n\ngit+tag dependency, which `.cargo/config.toml`\n\nfetches via the git CLI.\n\nThe sections below cover the on-disk format, configuration, ACLs, and a local (non-Docker) worked example.\n\n```\n            slater-build                         slater (Bolt server)\n   dump.cypher ──────────▶ /data/<graph>/<uuid>/ ──────────▶ neo4j driver\n   (offline, atomic)        MANIFEST.json, *.blk,            (bolt / bolt+s)\n                            range/*.isam, vector/*.{vamana,pq},\n                            current → <uuid>\n```\n\n- A\n**generation** is one immutable directory: a`MANIFEST.json`\n\n(symbol tables, index descriptors, an optional encryption header), columnar block files (`node_props.blk`\n\n,`node_labels.blk`\n\n,`edge_props.blk`\n\n,`topology.csr.blk`\n\n,`vectors.f32.blk`\n\n), range indexes (`range/<name>.isam`\n\n), above-threshold ANN indexes (`vector/<label>.<prop>.{vamana,pq}`\n\n), and a`current`\n\ntext pointer. - Every block is zstd-compressed and BLAKE3-checksummed; with\n`--encrypt`\n\neach block is additionally sealed with XChaCha20-Poly1305 (AEAD at rest). - The server opens a generation by\n**re-hashing every file** against the manifest, so a half-copied / truncated image — a torn copy onto the data dir, which may be remote/network storage — is refused rather than served. - Reads flow through\n**three bounded cache pools**— a decompressed-block LRU, a vector-index pool (resident PQ codes + a Vamana-block LRU), and a result LRU — each with its own byte budget. This is what keeps RSS flat.\n\nWith `delta.enabled`\n\n, the immutable generation becomes the **fully-compacted bottom\nlevel (the \"core\")** of a small log-structured-merge tree, and live writes ride on\ntop of it:\n\n```\n   write (Bolt)                                    read (Bolt)\n        │                                               │\n        ▼                                               ▼\n   ┌──────────────┐  flush   ┌──────────────┐    ┌──────────────────────┐\n   │ WAL + active │ ───────▶ │  L0 delta    │    │  a query pins one    │\n   │  memtable    │          │  segments    │    │  (core, delta) view  │\n   └──────────────┘          └──────┬───────┘    │  and reads the merge │\n        (fsync = ack)               │            └──────────────────────┘\n                          consolidation │  (folds core + delta → fresh core)\n                                        ▼\n                                 ┌─────────────┐\n                                 │  new core   │  (atomic `current` swap)\n                                 └─────────────┘\n```\n\n**Durability floor — the WAL.** Each mutation is serialised behind a single writer per graph, appended to a per-graph write-ahead log, and`fsync`\n\ned before the Bolt`SUCCESS`\n\nis returned — so*acknowledged ⇒ durable*, and a torn tail is dropped on replay. A batched write-`UNWIND`\n\nappends its rows and commits**one**`fsync`\n\nfor the whole batch. The WAL is**local disk only**(it is not routed through the storage backend), which makes a*writer*node stateful: it needs a durable local volume at`delta.walDir`\n\n. Read replicas stay stateless.**Memtable → L0 → consolidation.** Writes accumulate in an in-RAM memtable (bounded by`delta.memtableBytes`\n\n); when it fills it flushes to an immutable L0 delta segment. A**consolidation** folds`{core + delta}`\n\ninto a fresh core by serialising the merged view back through`slater-build`\n\nand swapping`current`\n\natomically — the same content-hash guard as any published generation. Trigger it by hand with`CALL slater.consolidate()`\n\n, automatically at`delta.deltaCorePercent`\n\nof the core's size (optionally gated to an off-peak`delta.consolidateWindow`\n\n), or let the`delta.deltaHardBytes`\n\nthrottle backstop runaway growth.**The overlay sits below the read surface.** The executor reads through a`ReadView`\n\nthat is either the bare core (delta always empty) or a merged`(core, delta)`\n\nview; the engine is monomorphised over it, so an empty delta compiles to a single predictable branch and the read-only path is byte-identical. Whole-graph counters (`count(*)`\n\n, label/reltype marginals) are served from the delta's own live counters, so they stay metadata reads even with writes pending.**A query sees a stable snapshot.** It pins one`(core, delta)`\n\ntuple for its whole life. There are no multi-statement transactions and no rollback — a write is a durable, business-key-addressed correction, not an OLTP transaction.\n\nThe exact write grammar and knobs are in the [Configuration](#environment--configuration)\ntable (`delta.*`\n\n) and the [Worked example](#worked-example) below.\n\nA range index (`range/<name>.isam`\n\n, one per indexed `(label, property)`\n\n) lets a\n`MATCH (n:Label {prop: v})`\n\nor `WHERE n.prop <op> v`\n\nresolve to the matching node\nids **without scanning the label**. It is an\n** ISAM** (Indexed Sequential Access Method)\nstructure — the classic\n\n*static, sorted, block-structured*index, which is exactly the right shape for an immutable generation: there are no inserts to rebalance, so the simplicity of ISAM buys what a B-tree's mutation machinery would only complicate.\n\n- Entries\n`(value, entity_id)`\n\nare sorted by value and packed into the same zstd-compressed 256 KiB blocks as everything else. - A small\n**resident top-level** holds the first key of each block (a sparse index). A lookup binary-searches that in-memory top level to find the*one*block a key can be in, reads + decompresses that block, and scans it — so an equality lookup is**one block read**, and a range scan walks the contiguous run of blocks it spans. (This is why a`meshUi`\n\n-indexed lookup is single-digit milliseconds while the same match on an unindexed property scans the whole label.) - The planner picks it via\n`NodeScan::RangeEq`\n\n/`RangeRange`\n\n; an unindexed predicate falls back to a label sweep or full scan, with the executor re-checking every predicate either way.\n\nVector KNN (`db.idx.vector.queryNodes`\n\n) runs over **cosine, L2, or dot-product (MIPS)**\nindexes. The base index is built offline with two execution paths, chosen per index by\nthe `--ann-threshold`\n\n(default 50 000 vectors):\n\n**Below the threshold — brute force.** The full`f32`\n\nvectors live in`vectors.f32.blk`\n\n; a query scans the index's group and computes the exact distance in the index's metric. Simple and exact; fine when the vector set is small.**At or above the threshold — Vamana + PQ**, the disk-native ANN path that keeps resident memory bounded regardless of how many vectors there are:is the graph index from the DiskANN line of work: a single proximity graph whose edges are pruned (the[Vamana](https://arxiv.org/pdf/2401.11324)`--vamana-r`\n\nout-degree and`--vamana-alpha`\n\nlong-edge factor) so a*greedy beam search*— start at the medoid, repeatedly hop toward the query, keeping a candidate list of width`vectorQuery.beamWidth`\n\n— reaches a node's true neighbours in few hops, i.e.**few random block reads per query**. The graph blocks (`vector/<label>.<prop>.vamana`\n\n) are paged in through the vector cache, not held wholesale.compresses each vector into a short code ([Product quantisation (PQ)](https://medium.com/aiguys/product-quantization-k-nn-for-big-datasets-12431d764c4e)`--pq-subspaces`\n\n×`--pq-bits`\n\n): the dimensions are split into subspaces, each independently k-means-clustered, and the vector is stored as the tuple of nearest-centroid ids. These codes (`vector/<label>.<prop>.pq`\n\n) are small enough to keep**resident**, so the beam search scores candidates from RAM and only the chosen few full vectors are read from disk. That resident PQ set is what the`cache.vectorCacheBytes`\n\npool pins.\n\n**Writable embeddings — the vector write ladder ( FreshDiskANN-style).**\nAn indexed embedding is a first-class writable value.\n\n`SET n.embedding = vecf32([…])`\n\n(and `REMOVE`\n\n) lands in the\nwrite delta and is **immediately KNN-visible with exact rank**, then survives a segment flush, a merge, and a consolidation. A query merges up to three levels — the sealed base index, a sealed per-segment index, and an in-memory\n\n**RW-index**(a live mutable Vamana over the write delta) — so latency stays flat as writes accumulate rather than growing with the count of pending writes. A delete leaves a\n\n*hole*: the node stops being returned but stays a navigational waypoint until a background\n\n**delete-consolidation** splices it out of the graph, so deletes stop costing query IO. And because the on-disk graph addresses its neighbours by layout position rather than node id,\n\n`CALL slater.consolidate()`\n\ncarries the Vamana **by reference**— hard-linked, byte-identical — and rewrites only a small id column, folding vector writes into the base\n\n**without** the O(N·R·L) graph rebuild. Measured numbers, with caveats, are in the\n\n[performance report](/Hikari-Systems/slater/blob/main/docs/PERF-REPORT.md).\n\nEvery generation file is opened through an ** ObjectStore** abstraction rather\nthan\n\n`std::fs`\n\ndirectly, so the *same*on-disk byte format — blocks, indexes, manifest,\n\n`current`\n\npointer — is served unchanged from any backend; only *where the bytes come from*differs, never the readers, the query engine, or the integrity checks. The hot path is positional reads (\n\n`read_exact_at`\n\n), which map\nonto a `pread`\n\non a local file and an HTTP byte-range request on an object store\n— Slater never mmaps, so the explicit, bounded-read model is identical\neverywhere.**Three first-class backends**, selected by `dataBackend.kind`\n\n. The filesystem is\nthe simple default; **Amazon S3 and Google Cloud Storage are equal, fully\nsupported object-store backends** — the published image ships with both compiled\nin, so each is configuration-only, and a generation built once can be served from\nany of them (even migrated `fs`\n\n→ S3 → GCS) without a rebuild.\n\n`dataBackend.kind` |\nPositional read | Integrity at open | Credentials |\n|---|---|---|---|\n`fs` (default) |\n`pread` |\nfull BLAKE3 re-hash of each file | — |\n`s3` |\nHTTP `Range` GET |\nserver SHA-256 via `HEAD` (→ BLAKE3 body re-hash if absent) |\nconfig keys, AWS chain, or IAM role |\n`gcs` |\nHTTP range read | server CRC32C via `get_object` (→ BLAKE3 body re-hash if absent) |\nADC / Workload Identity, or service-account JSON |\n\nBoth object stores verify integrity from the **checksum the store already\ncomputes and keeps**, fetched as object metadata: `slater-build`\n\nsends the\nchecksum on upload (the store validates the bytes against it and stores it), and\nthe server reads it back at open and compares it to the manifest — one metadata\nrequest per file, no body download. It is content-grade and identical in spirit\nacross S3 (SHA-256) and GCS (CRC32C). When an object carries **no** server-stored\nchecksum (copied in out-of-band, or uploaded with a different default), the server\n**re-hashes the object body against the manifest BLAKE3** rather than trusting its\nbyte length — a requested integrity check is never silently downgraded to a size\ncomparison. Slater-published generations always carry the checksum, so they stay on\nthe cheap metadata path.\n\nThe default, rooted at `dataBackend.fs.dir`\n\n. The right choice for most\ndeployments: a generation on a local SSD (or an NFS/EBS mount) served read-only.\nIntegrity is a full BLAKE3 re-hash of every file at open.\n\nAn S3 or S3-compatible bucket (AWS, MinIO, localstack). Credentials come **first**\nfrom config (`dataBackend.s3.awsAccessKey`\n\n/ `awsSecretKey`\n\n, plus\n`awsSessionToken`\n\nfor temporary STS credentials) and fall back to the standard AWS\nchain (`AWS_ACCESS_KEY_ID`\n\n/ `AWS_SECRET_ACCESS_KEY`\n\nenv, shared profile, or\ninstance/IRSA role) when left empty.\n\n``` js\n# serve from S3 (env-var form; see the config table for every key)\ndataBackend__kind=s3\ndataBackend__s3__bucket=slater\ndataBackend__s3__region=eu-west-2\ndataBackend__s3__awsAccessKey=…        # omit to use the AWS chain / instance role\ndataBackend__s3__awsSecretKey=…\n# S3-compatible (e.g. MinIO): also set\ndataBackend__s3__endpoint=http://minio:9000\ndataBackend__s3__pathStyle=true        # required by most S3-compatible servers\n# publish a generation into the bucket (remote `current` pointer written last)\nslater-build --input people.cypher --graph people --data-dir /data \\\n  --publish-s3-bucket slater --publish-s3-region eu-west-2 --publish-s3-prefix prod\n#   MinIO: add  --publish-s3-endpoint http://localhost:9000 --publish-s3-path-style\n```\n\nA GCS bucket, reached over the JSON API. Authorization is GCP-native: by default\nit resolves **Application Default Credentials** — GKE Workload Identity, the GCE\nmetadata server, or a `gcloud`\n\n/ `GOOGLE_APPLICATION_CREDENTIALS`\n\nkey. Set\n`dataBackend.gcs.credentialsPath`\n\n(a service-account JSON key file) or inline\n`credentialsJson`\n\nfor an explicit key. `dataBackend.gcs.endpoint`\n\npoints at a\n`fake-gcs-server`\n\nemulator, and `dataBackend.gcs.anonymous=true`\n\nenables\nunauthenticated access **for that emulator only** — never against real GCS.\n\n``` js\n# serve from GCS (env-var form; see the config table for every key)\ndataBackend__kind=gcs\ndataBackend__gcs__bucket=slater\ndataBackend__gcs__prefix=prod\ndataBackend__gcs__credentialsPath=/secrets/sa.json   # omit for ADC / Workload Identity\n# publish a generation into the bucket (remote `current` pointer written last)\nslater-build --input people.cypher --graph people --data-dir /data \\\n  --publish-gcs-bucket slater --publish-gcs-prefix prod\n#   explicit key: add  --publish-gcs-credentials /secrets/sa.json\n```\n\nIn all cases `slater-build`\n\nwrites the finished generation to `--data-dir`\n\nfirst\n(its local staging area) and **additionally** uploads it to the bucket; the remote\n`current`\n\npointer is written last, so a serving node never sees a half-published\ngeneration.\n\nReach for `s3`\n\nor `gcs`\n\nwhen you want generations in durable, central object\nstorage rather than on a node's disk — typically: publish once and fan out to many\nstateless, disk-less server replicas that all read the same bucket; decouple the\nbuild host from the serve hosts; or lean on the store's\ndurability/versioning/lifecycle instead of managing volumes. The trade-off is\nlatency: a cold block is a network round-trip (~10–50 ms) instead of a local read\n(~0.1 ms). Slater hides most of it with the in-memory block cache, concurrent\nread-ahead, **and** the optional disk cache below. If your generations already sit\non fast local storage and you don't need the central-bucket model, `fs`\n\nis simpler\nand faster.\n\nThe in-memory `BlockCache`\n\nis deliberately small (bounded RSS is the headline\nguarantee), so on a working set larger than RAM the same blocks would be\nre-fetched from the object store on every spill. An **optional local-SSD second\ncache tier** fixes that: a block evicted from RAM is served from local disk\n(~0.1 ms) instead of a fresh object GET, surviving in-memory eviction and cutting\nobject-store request count/cost — bringing an object-store-backed node close to\nlocal-filesystem performance once warm. It is **opt-in** for both `s3`\n\nand `gcs`\n\n,\nenabled by setting `dataBackend.<s3|gcs>.diskCacheBytes > 0`\n\nand a writable\n`diskCacheDir`\n\n.\n\n- It caches the\n**sealed** bytes exactly as fetched — already compressed, and (for`--encrypt`\n\ngenerations) still AEAD-sealed —*below*decrypt/decompress. The cache layer never holds the encryption key and never re-encrypts, so at-rest status is preserved for free: an encrypted generation lands on disk still sealed. - Writes are\n**write-behind**: a miss returns the fetched bytes to the query immediately, then a background thread does the disk write and LRU trim, so the query path never blocks on disk I/O. Eviction keeps the cache within its byte budget; a per-file checksum verified on every read self-heals a corrupt cache file to a miss (→ refetch from the object store). `diskCacheDir`\n\n**must point at a real writable volume — never**(tmpfs is RAM and would defeat the bounded-RSS guarantee). The in-memory index that tracks it costs a little RAM (~tens of bytes per cached block), which counts against your RSS ceiling — size the directory ≫ the in-memory block cache.`tmpfs`\n\n- The tier's other RAM cost is the\n**write-behind queue**, which stages blocks on their way to disk. It is bounded at`blockCacheBytes / 8`\n\n(floored by`diskCacheBytes`\n\n) — 8 MiB at the default — and sheds rather than grows, so a cold scan cannot inflate it; a shed block simply refetches on its next miss. It needs no configuration: it scales with`blockCacheBytes`\n\n, so the disk tier adds no new number to the RSS budget beyond its index.\n\nA **read replica** runs with a **read-only root filesystem** and a non-root user\n(`appuser:1000`\n\n) — everything it needs is mounted read-only. A **writer**\n(`delta.enabled`\n\n) additionally needs one durable, writable volume for its WAL.\n\n| Path | Purpose | Notes |\n|---|---|---|\n`/data` |\nThe graph generations (`<graph>/<uuid>/…` + `current` ). |\nRead-only for replicas; produced by `slater-build` . May live on remote/network storage (e.g. NFS), so reads are not assumed to be fast local-SSD latencies. |\n`/sandbox` |\nPer-environment config overlay + secrets. | `/sandbox/config.json` is deep-merged over the baked-in `config.json` ; also holds `acl.json` , TLS PEM material, the at-rest key file. |\n`/tmp` , `/run` |\nScratch (`tmpfs` ). |\nA read replica never writes to disk by default. |\n(writer) `delta.walDir` |\nThe write-ahead log + L0 delta segments, when `delta.enabled` . |\nWritable, and a durable, real volume — never (it is the durability floor). A relative path resolves under the data dir; give a writer its own persistent volume here.`tmpfs` |\n(optional) disk cache |\nThe local-disk block cache, when `dataBackend.s3.diskCacheBytes` / `dataBackend.gcs.diskCacheBytes > 0` . |\nWritable, and a real volume — not . Used by the `tmpfs` `s3` and `gcs` backends; see\n|\n\nConfig is loaded by the house-standard layered loader: the baked-in `config.json`\n\n,\nthen `/sandbox/config.json`\n\ndeep-merged over it, then `KEY__sub`\n\nenvironment\noverrides (double underscore for nesting; keys match the camelCase config).\n\nEvery configuration knob — its camelCase key, the `KEY__sub`\n\nenvironment override, its default, and what it does — is tabulated in the ** Configuration reference**. The most-tuned knobs are the cache budgets (\n\n`cache.*`\n\n), the query guards (`query.*`\n\n), the connection caps (`server.*`\n\n), the storage backend (`dataBackend.*`\n\n), and the writable layer (`delta.*`\n\n).**Resident memory** is approximately\n`blockCacheBytes + vectorCacheBytes + resultCacheBytes`\n\n+ a small fixed overhead\n(plus up to `degreeColumnBytes`\n\nfor the `lazy`\n\ndegree column, once the degree-sum\n`count(endpoint)`\n\nfast path is exercised), **independent of graph size** — that is\nthe headline guarantee, exercised by the\n`rss_stays_bounded_under_sustained_knn_load`\n\nintegration test. Per-connection\nbuffers live *outside* the cache budgets, so the guarantee holds under adversarial\nload only because `server.maxConnections`\n\nbounds how many can exist at once.\n\nSlater is a read replica handle; the **primary** connection-security control is the\nnetwork, not the binary. Bind it to a private interface, restrict source ranges at\nthe network layer (security groups / NetworkPolicy), and — if it faces anything but\ntrusted clients — front it with a connection-limiting L4 proxy (HAProxy `maxconn`\n\n+\na per-source `stick-table`\n\n, or nftables `connlimit`\n\n+ `hashlimit`\n\n). That sits before\nthe file descriptor is ever handed to the process, so it is the most robust limit.\n\nThe in-binary limits above (`maxConnections`\n\n, `maxPreAuthConnections`\n\n,\n`maxConnectionsPerIp`\n\n, the differential byte caps, and `loginTimeoutMs`\n\n) are\n**defence-in-depth**: they default on and generous so they are invisible to a\nlegitimate client population, but they make the bounded-RSS guarantee hold even when\nthe proxy is forgotten. See for the full\ndefensive posture, and\n\n`docs/HARDENING.md`\n\n`THREAT_MODEL.md`\n\n/ `SECURITY_WORKLIST.md`\n\nfor the canonical\ndetail.Slater polls each graph's `current`\n\npointer every `generationPollMs`\n\n(**poll, not inotify** — the data dir may be remote/network storage like NFS,\nwhere filesystem change events are unreliable). When it changes:\n\n`reloadStrategy=exit`\n\n(default): the server logs fatal and exits non-zero so the orchestrator restarts it cleanly against the new generation.`reloadStrategy=swap`\n\n: the server opens**and validates** the new generation (same content-hash guard as boot), atomically swaps it in, and lets in-flight queries finish on the old one. A corrupt/incomplete new image is refused and the old generation keeps serving.\n\n`acl.json`\n\nmaps users to argon2id password hashes and per-graph ** read** /\n\n**grants. Mint a hash (never store cleartext) with:**\n\n`write`\n\n```\nslater hash-password 's3cret'        # prints a $argon2id$… string for acl.json\n```\n\nA starter `acl.json`\n\nships at the repo root; its shape is:\n\n``` php\n{\n  \"users\": {\n    \"reporting\": {\n      \"passwordArgon2id\": \"$argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>\",\n      \"grants\": {\n        \"people\": [\"read\"],\n        \"products\": [\"read\", \"write\"]\n      }\n    }\n  }\n}\n```\n\n-\n— one entry per login, keyed by username.`users`\n\n-\n— the`passwordArgon2id`\n\n`$argon2id$…`\n\nstring from`slater hash-password`\n\n(never cleartext; the file itself is plain JSON and lives on shared storage). -\n— per-graph capability lists. Two permissions are meaningful:`grants`\n\n— query the graph. A graph absent from a user's grants is invisible to them.`read`\n\n— mutate the graph through the writable layer (`write`\n\n`delta.enabled`\n\n): the`MERGE`\n\n/`SET`\n\n/`DELETE`\n\nstatements and`CALL slater.consolidate()`\n\n.\n\nThey are\n\n**independent: a** Turning the writable layer on therefore cannot promote your existing readers into writers. A writer needs both —`read`\n\ngrant confers no write access.`[\"read\", \"write\"]`\n\n— because resolving a business key to write it is a read. Unrecognised permission strings are ignored (they grant nothing).\n\nMount it read-only at the path named by `aclPath`\n\n(default `/config/acl.json`\n\n).\nThe server reloads it on each generation hot-swap, and the at-rest ACL stamp is\nre-checked on every reload (see [ requireAclStamp](#environment--configuration)).\n\nThe `slater`\n\nbinary doubles as its own liveness probe: `slater healthcheck [host] [port]`\n\nperforms a **Bolt handshake** (not an HTTP request) against the server and\nexits `0`\n\nif it negotiates a protocol version, `1`\n\notherwise — defaulting to\n`localhost`\n\nand the configured Bolt port. This is what the container\n`HEALTHCHECK`\n\nruns, so orchestrators see a truly Bolt-ready server, not just an\nopen socket:\n\n```\nslater healthcheck localhost 7687    # exit 0 = healthy\ndocker exec slater /app/slater healthcheck   # inside the container\n```\n\nFor scripting, CI checks, and quick lookups, `slater query`\n\nmounts a graph's\ncurrent generation, runs a single read-only Cypher query in-process, prints the\nresult as a JSON object, and exits — no server, no Bolt connection. It honours\nthe same config as the server (storage backend, encryption key, query budgets):\n\n```\n# GRAPH defaults to `defaultGraph`. Without -q, normal datestamped logging\n# (config, \"opened generation\", …) is written to stdout alongside the result.\nslater query mygraph 'MATCH (n) RETURN count(n) AS c'\n\n# -q/--quiet ⇒ logging suppressed, so stdout is *only* the compact result JSON\nslater query mygraph -q 'MATCH (c:Company) RETURN c.ticker AS t LIMIT 3' | jq\n# {\"columns\":[\"t\"],\"rows\":[[\"AUPH\"],[\"KYMR\"],[\"MREO\"]]}\n```\n\nNodes and relationships expand to their labels/type and properties. Use `-q`\n\nwhen you want machine-parseable output (the result JSON is the only thing on\nstdout); omit it for an operator-facing run with logs. Without `-q`\n\na\nmetrics-only summary is logged after each run — e.g.\n\n```\nINFO query executed cost=2389 resultCount=10 execMs=441 limitRowCount=10\n```\n\ncarrying the query `cost`\n\n(elements charged), `resultCount`\n\n, `execMs`\n\n, and\n`limitRowCount`\n\n(only when the query specifies a `LIMIT`\n\n) — never the query text\nor any result value. Exit status is `0`\n\non success, `1`\n\non a parse/open/execute\nerror (message on stderr).\n\n`slater dump`\n\nexports a graph from a **running** server as business-key `MERGE`\n\nCypher — the same dialect `slater-build`\n\ningests — so a graph round-trips\n(dump → `slater-build`\n\n→ new generation) for migration or text backup. Unlike\n`slater query`\n\n, it connects over **Bolt**, authenticates, and honours per-graph\nACLs, so it needs no disk access to the server. The password is read from\n`SLATER_DUMP_PASSWORD`\n\nor stdin (never a flag, keeping it out of `ps`\n\n/history).\n\n```\n# List the graphs the authenticated user may read.\nSLATER_DUMP_PASSWORD=pw slater dump --list -u reporting\n\n# Dump a graph to a file (identity keys inferred from range indexes).\nSLATER_DUMP_PASSWORD=pw slater dump people -u reporting -o people.cypher\n\n# Rebuild it into a fresh generation.\nslater-build --input people.cypher --graph people --data-dir ./data\n```\n\nEach label's identity key is the property carried by its range index; override\nwith `--key Label=prop`\n\n(repeatable) or a global `--pk <field>`\n\n. `CREATE INDEX`\n\nDDL is emitted first so the rebuild recreates the indexes. A multi-label node\nkeeps **every** label — it is emitted as `MERGE (n:Ident:Other {key: v})`\n\n, with\nthe identity label (the one supplying the business key) first and the rest\nsorted; the merge is keyed on the identity label alone, so the trailing labels\nare written onto the node without creating another one. Labels, relationship\ntypes and property keys containing special characters are backtick-quoted on\nemission, so unusual names round-trip faithfully and cannot inject Cypher into the\nrebuild. Vectors (and other\nvalues with no Cypher-literal spelling) cannot ride a `MERGE`\n\ndump and are dropped\nwith a warning on stderr. Exit status is `0`\n\non success, `1`\n\non error.\n\nA complete, runnable walkthrough — build a graph, serve it, connect with the neo4j **JavaScript** and **Python** drivers, and write to it — is in the manual's ** Quickstart** and\n\n**pages, using the bundled sample graph in**\n\n[Writing data](/Hikari-Systems/slater/blob/main/docs/manual/11-writing-data.md)[.](/Hikari-Systems/slater/blob/main/docs/manual/examples)\n\n`docs/manual/examples/`\n\n```\nexport PATH=\"$HOME/.cargo/bin:$PATH\"\ncargo build\ncargo test            # unit + the bounded-RSS headline integration test\ncargo clippy --all-targets -- -D warnings\ncargo fmt --all -- --check\n```\n\nA plain `cargo build`\n\nproduces a **filesystem-only** binary — the `s3`\n\nand `gcs`\n\nbackends are gated behind cargo features so the default build stays small (no AWS\nor Google SDK, no async runtime). Enable whichever you need on **both** `slater`\n\n(serve) and `slater-build`\n\n(publish):\n\n```\n# S3 only / GCS only / both\ncargo build -p slater -p slater-build --features s3\ncargo build -p slater -p slater-build --features gcs\ncargo build -p slater -p slater-build --features s3,gcs\n```\n\nEach crate exposes matching `s3`\n\n/ `gcs`\n\nfeatures that forward to\n`graph-format/{s3,gcs}`\n\n. Requesting a backend at runtime\n(`dataBackend.kind=s3|gcs`\n\n, or `slater-build --publish-{s3,gcs}-*`\n\n) without its\nfeature compiled in fails fast with a clear \"built without the … feature\" error.\nThe **published Docker image enables both** (Dockerfile `CARGO_FEATURES`\n\n), so\nprebuilt images need no extra flags — this only matters when building from source.\nThe integration tests are likewise gated: `--features s3 --test s3_minio`\n\n,\n`--features gcs --test gcs_emulator`\n\n(a `fake-gcs-server`\n\n), and `--features gcs --test gcs_real`\n\n(real GCS via ADC); each skips unless its `SLATER_*`\n\nenv vars are\nset.\n\nSee `docs/PLAN.md`\n\n, `docs/PROGRESS.md`\n\nand `docs/DECISIONS.md`\n\nfor the design,\nthe milestone ledger, and the decision log.\n\nUp to six engines, one single-client suite, graphs from a 62k-node toy to **Wikidata\n91.6M nodes / 1.5B edges**. Each engine is **measured in isolation** (every other container\nstopped — RSS and latency are its own footprint). The **latency tables** below were\n**re-measured on Slater 0.21.0** (the writeable build): the small/medium graphs (MeSH, EU-AI-Act)\nfreshly, and the 91.6M graph as a fresh **same-box, shared-anchor** slater-vs-Neo4j pass (see\nthat table). The **resident-memory** figures carry forward from the earlier pass (measured via\ncontainer cgroup; the read path is byte-identical with the writable layer idle). The other\nengines' numbers are the established cross-engine run (their versions/performance are unchanged).\nAll figures are medians (ms) or peak resident memory (MiB). **Lower is better everywhere; bold =\nbest in row.** slater was run on its **local-filesystem ( fs) backend**; the S3 and GCS backends trade local-read\nlatency for object-store round-trips (mitigated by the in-memory caches and the optional local-disk\ncache tier), so these figures characterise the engine, not a network-storage deployment.\n\n| engine | class | memory bound |\n|---|---|---|\nslater |\ndisk-backed, paged | `query.maxIntermediate` caps the working set automatically |\n| Neo4j 5 | disk-backed, JVM | ~2 GiB heap + off-heap, committed regardless of query |\n| Memgraph · FalkorDB | in-memory | whole graph resident in RAM |\n| ArcadeDB | in-memory, JVM | whole graph resident; heaviest |\n| LadybugDB | embedded, columnar | manual buffer pool that must exceed the query |\n\nThe three engines that **page from disk** — slater, Neo4j 5, and LadybugDB — load all five\ngraphs. The **in-memory trio** (Memgraph · FalkorDB · ArcadeDB) cannot hold the 1.5B edge graph at\nall (it needs ~64–128 GiB resident), and ArcadeDB's importer can't finish it either.\n\nEach figure is **committed working memory** — what the OS cannot reclaim. Every engine *except*\nslater holds its graph in committed anonymous memory (own heap, Neo4j's off-heap page cache, or\na buffer pool), so its peak RSS *is* its committed footprint. slater alone serves from the\n**reclaimable OS page cache** of its on-disk store, so its figure is the anon working set; the\nstore's page cache (evictable under pressure — slater keeps serving) is excluded, and shown as\n*total* in parentheses for the 91.6M graph. **Bold = lowest.**\n\n| graph (nodes / edges) | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |\n|---|---|---|---|---|---|---|\n| pole — 62k / 106k | 11 |\n746 | 114 | 140 | 1,556 | 198 |\n| MeSH — 341k / 469k | 63 |\n1,083 | 358 | 455 | 1,631 | 121 |\n| EU-AI-Act — 21k / 45k (+55 MiB vec) | 99 |\n729 | 229 | 312 | 1,948 | 286 |\n| Wikidata — 91.6M / 1.5B | 584 (4,595 total) |\n~2,900 | cannot-load | cannot-load | cannot-load | ~652 † |\n\nslater is the **lowest at every scale** and grows ~50× while the graph grows ~1,500× — its\nfootprint tracks the *query working set*, not the graph (idle ~16–71 MiB throughout). The\nin-memory trio grows ~linearly and can't load the 1.5B graph; Neo4j commits a ~2 GiB heap\nregardless of query. († LadybugDB on the bounded shapes only — its hub / var-length /\nshortestPath traversals at 1.5B edges need its read pool raised to ≥2 GiB, vs slater's automatic\n`maxIntermediate`\n\ncap.) The build-time value→count histograms add negligible resident memory —\na few KB for a low-cardinality indexed column, and *zero* for unique-key graphs like Wikidata\n(`wikidata_id`\n\nexceeds the histogram cardinality cap, so none is stored) — so these figures are\nunchanged by that feature.\n\n| shape | slater | Neo4j 5 | Memgraph | FalkorDB | ArcadeDB | LadybugDB |\n|---|---|---|---|---|---|---|\n| count(*) all nodes | 0.41 |\n15.0 | 23.8 | 16.4 | 82.0 | 2.2 |\n| label count | 0.42 |\n4.2 | 20.7 | 1.1 | 4.4 | 4.3 |\n| indexed point lookup | 0.43 |\n3.9 | 0.48 | 0.48 | 0.65 | 8.8 |\n| idx-eq count | 0.42 |\n4.9 | 5.0 | 2.0 | 381 | 2.5 |\n| 1-hop (indexed anchor) | 1.28 | 5.8 | 1.21 |\n4.1 | 390 | 4.9 |\n| 2-hop (unanchored) | 1.40 |\n5.6 | 8.5 | 16.7 | 444 | 6.4 |\n| group-by / count(DISTINCT) | 0.45 |\n47–51 | 63–64 | 31–39 | 411 | 5.3 |\nfull-scan `CONTAINS` |\n0.43 |\n5.4 | 24.1 | 1.7 | 16.3 | 4.1 |\n\nslater owns the **metadata / index / scan** shapes (count, label, idx-eq, scan — ~0.4 ms, 10–200× the\nservice engines), the **indexed point lookup** (0.43 ms, now edging the in-memory pair's 0.48 ms),\nthe **unanchored multi-hop** (2-hop 1.40 ms via the relationship-type scan, fastest in the field),\nand — via a build-time value→count histogram on the indexed grouping key — the **whole-label\ngroup-by / count(DISTINCT)** (0.45 ms, ahead of LadybugDB's columnar 5.3 ms). The in-memory servers\nkeep only **raw 1-hop** (Memgraph 1.21 ms vs slater's 1.28 ms). (pole 62k/106k looks the same:\nslater sole-fastest on count/scan ~0.4 ms, ~1.3–2.6 ms on hops.)\n\n| shape | slater | Neo4j 5 | Memgraph | FalkorDB | LadybugDB |\n|---|---|---|---|---|---|\n| kNN top-10 Concept | 2.9 | 8.6 | 1.9 | 1.2 |\n2.8 |\n| kNN top-10 Chunk | 2.4 | 5.7 | 1.9 | 1.5 |\n3.2 |\n\nslater answers kNN with an **exact brute-force** scan (these sets are below its 50k-vector\nANN threshold) where the others use an approximate resident HNSW — so slater's results are\nexact (recall 1.0). A SIMD distance kernel + a resident, pre-normalised vector matrix\ntook Concept from ~23 → ~2.9 ms and Chunk from ~10 → ~2.4 ms, so slater now beats\nNeo4j and LadybugDB and is within ~1.4× of Memgraph, trailing only FalkorDB — while exact.\n\nThe tables above are cross-engine *read* comparisons. The vector **write** path (the\n[FreshDiskANN](https://arxiv.org/abs/2105.09613)-style write ladder over the static Vamana\nbase) has no cross-engine counterpart — no other engine here does disk-native, *writable*\nANN — so the numbers below are single-engine **component** benchmarks over a synthetic,\nembedding-like fixture (a low-rank manifold, dim 768, unequal norms), committed under\n[ crates/slater/benches/](/Hikari-Systems/slater/blob/main/crates/slater/benches) and written up in full — with the\nmethodology and every caveat — in\n\n[. Recall is always measured against an](/Hikari-Systems/slater/blob/main/docs/PERF-REPORT.md)\n\n`docs/PERF-REPORT.md`\n\n**exact brute force over the live set**, never one index against another. Scale here is representative and extrapolated only where the metric is size-linear.\n\n| property | measured | why it matters |\n|---|---|---|\nKNN latency vs pending writes |\nRW-index ~1.5–2 ms, flat to 50k pending; the pre-index brute-forced overlay 1.9 → 115 ms (linear in delta) — 61× at 50k |\nquery latency does not degrade as writes pile up between consolidations |\nEmbedding insert |\n~1.5–2 ms per vector into the live index |\na write is KNN-visible at once; the delta-rebuild budget is ≈ 2 ms × the delta cap |\nDelete IO at iso-recall |\n2.9× fewer node-fetches per query at 67 % deleted, 5.2× at 80 % (recall ≥ 0.90) |\na consolidated graph pays no read tax for deleted vectors |\nConsolidation, pure permutation |\nO(1) — the `.vamana` is hard-linked byte-identical, only the id column is rewritten |\nfolding vector writes into the base skips the O(N·R·L) rebuild |\nRecall across the ladder |\nconsolidated ≥ base for cosine, L2 and dot | the write ladder preserves recall at every rung |\n\nThe one figure that wants the dedicated perf box is the *slow-path* consolidation rewrite\nthroughput — when a consolidation carries deletes or new vectors rather than a pure\npermutation, it is a sequential recompress bound by single-thread zstd and local disk, so\nthe absolute MiB/s is environment-specific (the report shows the shape and explains the\nenvironmental range).\n\nThe in-memory engines (Memgraph / FalkorDB / ArcadeDB) **cannot load** this graph at all\n(~64–128 GiB resident). Only slater and Neo4j 5 do. This is a **fresh same-box, same-day pass\nagainst a shared, fixed anchor set** — every query hits the *identical* nodes on both engines,\nso the head-to-head is apples-to-apples (a common `wikidata_id`\n\npool of moderate-degree anchors;\nsee the note below on why that matters). slater is shown at both fanouts (`query.maxFanout`\n\n1 =\nthroughput default, 8 = the latency dial that overlaps cold block reads). **Bold = best in row.**\n\n| shape | slater (fan 1) | slater (fan 8) | Neo4j 5 |\n|---|---|---|---|\n| count(*) all nodes | 0.41 |\n0.41 | 3606 |\n| point lookup (indexed) | 0.72 | 0.49 |\n6.3 |\n| degree (1-hop count) | 0.43 |\n0.44 | 6.0 |\n| 1-hop neighbours | 9.8 | 4.5 |\n10.1 |\n| 2-hop | 37 | 23 |\n34.5 |\n| 3-hop | 32 | 25 |\n74 |\nvar-length `*1..2` distinct |\n985 | 1056 | 47 |\n\nThe honest picture: slater **dominates the metadata / index shapes** — `count(*)`\n\nis\nmetadata-served (**0.41 ms vs Neo4j's 3.6 s** disk scan, ~8800×), and point-lookup / degree /\n3-hop run ~2–10× faster — is **even with Neo4j on 1–2-hop** (fanout 8 pulls ahead on cold reads),\nbut **loses var-length *1..2 distinct decisively (≈1 s vs Neo4j's 47 ms)**: slater's\nvariable-length distinct expansion is materially slower here, a real weakness worth its own\ninvestigation. All of that at a few hundred MB of RSS versus Neo4j's committed ~2 GiB heap.\n\nOn the anchors.These traversal numbers depend heavily onwhichnodes you start from — a node one link from a Wikidata mega-hub (\"human\", \"country\") has a millions-strong 2-hop neighbourhood, so var-length/hop cost swings by orders of magnitude with anchor choice. The earlier edition of this table sampled each engine'sown\"first N by scan,\" which is neither stable nor comparable; this pass fixes a single shared, degree-bounded anchor set for both engines. (shortestPath is omitted from this pass — between two arbitrary anchors it is path-existence-dependent and too high-variance to median meaningfully.)\n\nUncapped multi-hop `RETURN count(*)`\n\ncounts *during* expansion instead of materialising\nthe matched rows. Same hub anchors on the 91.6M graph, `maxIntermediate=20M`\n\n:\n\n| 3-hop count(*) @ 91.6M | fanout=1 | fanout=8 |\n|---|---|---|\n| latency / peak working set | 554 ms / 0.66 GiB |\n298 ms / 1.9 GiB |\n\nThe count holds O(1) rows. Charging is unchanged, so a mega-hub count still trips\n`maxIntermediate`\n\non *compute* (adjacency reads), bounded as before.\n\nRaising `query.maxFanout`\n\noverlaps a query's **cold, I/O-bound** block reads across cores —\nit helps large-cold-working-set disk-bound shapes and is flat on warm shapes. On the 1.5B\ngraph: shortestPath ≤6 **918 → 608 ms** (1.5×, largest search 6,269 → 2,350 ms, 2.7×);\n3-hop count **547 → 298 ms**. `maxFanout=1`\n\nis the default (throughput-oriented); `8`\n\nis the\nlatency dial, at more transient worker memory.\n\n| dimension | slater | best of the field | verdict |\n|---|---|---|---|\n| resident memory, any scale | 11–584 MiB (62k → 91.6M) | in-memory 1.5–2.7 GiB; can't load 1.5B | slater |\n| count / metadata / scan | ~0.4 ms | service engines 5–80 ms | slater (10–200×) |\n| indexed point lookup | 0.43 ms (MeSH) |\nMemgraph · FalkorDB 0.48 ms | slater (edges the in-memory pair) |\n| unanchored multi-hop (rows) | 1.40 ms (MeSH 2-hop) |\nNeo4j 5.6 ms | slater (relationship-type scan) |\n| aggregation (group-by / DISTINCT) | 0.45 ms |\nLadybugDB 5 ms (columnar) | slater (build-time histogram) |\n| kNN | 2.4–2.9 ms (exact) | FalkorDB 1.2 ms (HNSW) |\nbeats Neo4j/Ladybug; ~1.4× off Memgraph; exact |\n| 91.6M metadata / point / degree / 3-hop | 0.4–32 ms | Neo4j 6–3,600 ms | slater (2–8800×) |\n| 91.6M 1–2-hop | 4.5–23 ms (fan 8) | Neo4j 10–35 ms | ~even |\n91.6M var-length `*1..2` distinct |\n~1 s | Neo4j 47 ms |\nNeo4j (a real slater weak spot) |\nmulti-hop `count(*)` at scale |\n0.3–0.6 GiB | in-memory engines materialise the row set | slater, bounded |\n\nFull per-engine tables (pole, MeSH, EU-AI-Act + the `blockCacheBytes`\n\nRAM↔latency dial,\nWikidata 1M & 91.6M) are in\n[ perf/cross-engine-hs/README.md](/Hikari-Systems/slater/blob/main/perf/cross-engine-hs/README.md); the fresh slater-only pass\n(both fanouts, every dataset) is in\n\n[.](/Hikari-Systems/slater/blob/main/perf/PERF_CURRENT_STATUS.md)\n\n`perf/PERF_CURRENT_STATUS.md`\n\nThe benchmarks above are single-client. The complementary axis — **behaviour under many\nconcurrent clients** — has its own harness, [ perf/loadtest/](/Hikari-Systems/slater/blob/main/perf/loadtest): a Locust\ndriver over Bolt plus a coordinator that ramps load, reads\n\n`CALL slater.diagnostics()`\n\n,\nfinds the capacity knee, and names the limiter (full method in\n[). Headlines from a 256 MiB-cache run on the Wikidata-1M graph (one 16-core box):](/Hikari-Systems/slater/blob/main/docs/LOAD-TESTING.md)\n\n`docs/LOAD-TESTING.md`\n\n| result | measurement |\n|---|---|\nHolds to 1000 concurrent clients, zero failures |\nthroughput peaks ~2.5k rps; the latency knee sets in around 750 clients (p99 51 → 750 ms) — queueing under core contention, not a hard cap (single-run, WSL2) |\nBlock cache bounded and effective |\n100% hit rate, 0 evictions, 50 MB resident for a cache-fitting working set |\n| RSS held under sustained load | the jemalloc allocator holds RSS to ~0.6 GB across a 100→500-client `wiki_cache_churn` ramp — cache-bound and stable, with no `MALLOC_*` tuning (the former `MALLOC_ARENA_MAX=2` + trim threshold is retired); its background purge also returns the post-burst high-water instead of leaving it pinned |\n| Aggregate memory bounded | server-wide + adjacency-charged expansion hold the `query.maxIntermediateGlobal` `wiki_budget` 2-hop flood at 1000 clients without OOM (RSS ~0.6 GB; the guard sheds ~60% of hub queries as retryable budget errors) |\n\nBoth memory issues the load test surfaced are now closed; all tracked in the load-testing doc.\n\nLicensed under the Apache License, Version 2.0. See [ LICENSE](/Hikari-Systems/slater/blob/main/LICENSE) for the\nfull text and\n\n[for attribution. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this work, as defined in the Apache 2.0 license, shall be licensed as above, without any additional terms or conditions.](/Hikari-Systems/slater/blob/main/NOTICE)\n\n`NOTICE`\n\nSPDX-License-Identifier: Apache-2.0", "url": "https://wpnews.pro/news/slater-low-memory-graphdb-designed-for-read-heavy-graphs", "canonical_source": "https://github.com/Hikari-Systems/slater", "published_at": "2026-07-21 18:39:41+00:00", "updated_at": "2026-07-21 18:42:46.944426+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-infrastructure", "ai-tools"], "entities": ["Hikari Systems", "Slater", "Neo4j", "Memgraph", "FalkorDB", "Wikidata"], "alternates": {"html": "https://wpnews.pro/news/slater-low-memory-graphdb-designed-for-read-heavy-graphs", "markdown": "https://wpnews.pro/news/slater-low-memory-graphdb-designed-for-read-heavy-graphs.md", "text": "https://wpnews.pro/news/slater-low-memory-graphdb-designed-for-read-heavy-graphs.txt", "jsonld": "https://wpnews.pro/news/slater-low-memory-graphdb-designed-for-read-heavy-graphs.jsonld"}}