Read-only, low-memory graphDB via Bolt+TLS, at-rest encryption, vectors for local replica use cases.
6.9K
A low-memory, Bolt-speaking graph + vector engine that serves graphs bigger than RAM — and takes live, durable writes.
Current version: v0.25.2 — all releases.
Slater serves a graph over the Bolt protocol (port 7687), so any standard
neo4j driver (JavaScript, Python, Go, Java, …) or cypher-shell can query it.
Its headline property: resident memory stays bounded by fixed cache budgets,
independent of graph size — it reads decompressed blocks on demand from disk
(local or network/NFS) rather than holding the whole graph in RAM. That is why it
serves graphs the in-memory engines cannot even load (Wikidata 91.6M nodes / 1.5B
edges from a few hundred MB of RAM).
It answers a broad slice of Cypher — pattern matching, WITH/UNION/CALL {…}
subqueries, 70+ functions & aggregations, temporal & geospatial values,
label/property index seeks, and disk-native vector KNN
(CALL db.idx.vector.queryNodes(...)). Graphs are compiled offline by
slater-build into immutable, content-hashed generations; the read hot path
carries none of the write-side machinery (logs, locks, GC), which is what keeps
reads fast and memory bounded. On top of that immutable core sits an opt-in
single-writer write layer (delta.enabled): business-key MERGE / SET /
DELETE over Bolt, group-committed and fsync-durable, folded back into a fresh
core by consolidation — so you correct, insert and retract without rebuilding the
graph, and reads stay byte-identical when no writes are pending.
📦 Source, issues & full documentation: github.com/Hikari-Systems/slater
This page covers running the image. The complete user manual — a
feature-by-feature guide to the query language, the build toolchain, vector
search, the writable layer, storage, configuration, security, and tuning — lives
in the repository at
docs/manual/.
| Feature | What it means for you |
|---|---|
| Bounded, predictable memory | Resident 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. |
| Multi-tenant out of the box | One server hosts many graphs with per-user read grants — multi-database isolation that most graph DBs reserve for a paid/enterprise tier. |
| Encryption at rest & in transit | Per-block XChaCha20-Poly1305 sealing (the key is never written to disk) plus optional TLS (bolt+s://). GDPR-friendly by construction. |
| Tiny install | A 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. |
| Built for periodic publish | Build a graph offline, serve it immutable, then atomically swap in a new version with zero downtime — ideal for data-warehouse / scheduled-refresh workloads. |
| Rugged under load | The server and offline builder both compile with #![forbid(unsafe_code)] — the engine's only unsafe lives in the audited jemalloc allocator crate. The immutable core means reads take no locks and never wait; 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. |
| Works with your neo4j tools | Speaks Bolt 5.4 / 4.4 / 4.1 — use the standard neo4j drivers (JS, Python, Go, Java…), cypher-shell, or graph browsers unchanged. |
| Rich Cypher query surface | A broad read surface: MATCH/WHERE/WITH/UNION, CALL {…} subqueries, 70+ functions & aggregations, temporal & geospatial values, and regex. |
| Live, durable writes | An opt-in single-writer LSM layer over the immutable core (delta.enabled): business-key MERGE / SET / DELETE over nodes and relationships, batched write-UNWIND (one fsync per batch), and CALL slater.consolidate() — group-committed, fsync-durable, folded back into a fresh core. Reads are byte-identical when the delta is empty. See Writing to a graph. |
| ISO GQL, read and write | Speaks 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 / DELETE) lower onto the same durable write path. Cypher and GQL, in one engine. See Querying with GQL. |
| Vectors + graph in one engine | Disk-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 in place (a FreshDiskANN-style write ladder): insert / update / delete a vector and it is KNN-visible at once, folded into the base without an offline rebuild. |
| Safe on network storage | Every 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). |
| Pluggable storage backends | Serve the same generation format from a local volume, an S3 (S3-compatible) bucket, or a Google Cloud Storage bucket — publish once, fan out to stateless replicas — with an optional local-disk cache tier in front of the object store. See Storage backends. |
-lite (same slater repo)Each release publishes two flavours, as tags of the one hikarisystems/slater
repo (multi-arch amd64 + arm64):
| Tag | Contains | Storage backends | Use it when |
|---|---|---|---|
:latest / :vX.Y.Z (full) | both binaries (slater + slater-build) | filesystem + S3 + GCS | the default — you want to build generations in-container and/or serve from (and publish to) S3 or GCS object storage. |
:latest-lite / :vX.Y.Z-lite | the server only (slater) | filesystem only | a smaller image / smaller dependency surface for the common serve-only case: serve a generation built elsewhere from a local or mounted volume. No object-store backends, no slater-build. |
Everything below uses the full tag but applies equally to the -lite tag for the
server bits (the slater-build and S3/GCS sections are full-only).
docker pull hikarisystems/slater:latest # full
docker pull hikarisystems/slater:latest-lite # server only, fs backend
The full image bundles two binaries:
| Binary | Role | How you run it |
|---|---|---|
slater | The online Bolt server: serves reads, plus the single-writer durable write path when delta.enabled. | Default entrypoint — just docker run the image. |
slater-build | The offline compiler: turns a Cypher dump into an immutable generation. | Override the entrypoint: --entrypoint /app/slater-build. |
slater-build compiles the immutable generations the server serves, on a shared /data
volume. A read replica never writes to disk; a writer (delta.enabled) additionally keeps
its write-ahead log on a durable local volume. (The :latest-lite tag ships only the
slater server.)
dump.cypher ──[ slater-build ]──▶ /data/<graph>/<uuid>/ + `current` ──[ slater ]──▶ neo4j driver
Passwords are stored as argon2id hashes, never cleartext:
docker run --rm hikarisystems/slater:latest hash-password 'choose-a-password'
# → $argon2id$v=19$m=19456,t=2,p=1$....
Create acl.json next to you, pasting that hash in:
{
"users": {
"reporting": {
"passwordArgon2id": "$argon2id$v=19$...your-hash...",
"grants": { "people": ["read"] }
}
}
}
grants maps graph name → permissions: read (query the graph) and write
(mutate it through the writable layer). They are independent — a read grant confers no
write access — so a writer needs both, ["read", "write"]. A user can only see graphs
they are granted. This quick start grants read only; see Writing to a graph
to enable writes.
Put a primitive-Cypher creation script (CREATE (...), CREATE (a)-[...]->(b))
in a local ./dumps folder, then write it into a named Docker volume:
docker volume create slater-data
docker run --rm \
-v slater-data:/data \
-v "$PWD/dumps:/dumps:ro" \
--entrypoint /app/slater-build \
hikarisystems/slater:latest \
--input /dumps/people.cypher --graph people --data-dir /data
This writes /data/people/<uuid>/… and a current pointer.
docker run -d --name slater \
-p 7687:7687 \
-v slater-data:/data:ro \
-v "$PWD/acl.json:/config/acl.json:ro" \
hikarisystems/slater:latest
The server reads /data (read-only) and the ACL at /config/acl.json (the
baked-in default path). It listens for Bolt on 7687.
Any neo4j driver works; the database name is the graph name. Example with
cypher-shell from a container on the same Docker network:
docker run --rm -it --network host neo4j:5 \
cypher-shell -a bolt://localhost:7687 -u reporting -p 'choose-a-password' -d people \
"MATCH (p:Person) RETURN p.name AS name ORDER BY name"
From application code use bolt://<host>:7687 (or bolt+s:// with TLS), basic
auth, and set the session database to the graph you want.
Alongside Cypher, Slater understands a subset of ISO GQL (ISO/IEC 39075) over the
same Bolt connection — no separate endpoint, no driver change. A statement may
optionally start with a GQL/CYPHER dialect selector (like Neo4j's CYPHER 5); it is
stripped and the one engine parses the rest. Every GQL form lowers onto an existing
capability, so GQL and Cypher spellings are equivalent and mixable — reads always, and
GQL's data-modifying statements too when the writable layer is on.
| GQL | Cypher equivalent |
|---|---|
MATCH (a) ((x)-[:R]->(y)){1,3} (b) | MATCH (a)-[:R*1..3]->(b) (quantified path) |
MATCH ACYCLIC (a)-[:R*]->(b) | path restrictor (WALK/TRAIL/ACYCLIC/SIMPLE) over -[:R*]-> |
MATCH ANY SHORTEST (a)-[:R*]->(b) | shortestPath((a)-[:R*]->(b)) (also ALL SHORTEST, SHORTEST k) |
MATCH (n:Person & !Admin) | label/type booleans & | !; :A:B stays AND sugar |
FOR x IN [1,2,3] RETURN x | UNWIND [1,2,3] AS x RETURN x |
RETURN CAST('42' AS INTEGER) | RETURN toInteger('42') |
INSERT (n:Person {name:'Zoe'}) | CREATE (n:Person {name:'Zoe'}) (writable layer; GQL's SET/REMOVE/DELETE are spelled as in Cypher) |
GQL MATCH (n) RETURN n | optional dialect prefix (no-op routing) |
Responses also carry GQLSTATUS objects in the Bolt SUCCESS/FAILURE metadata
(gql_status + status_description), added alongside the existing keys so older
drivers are unaffected.
Writes are off until you turn them on. Enable the writable layer with
delta__enabled=true, grant the user write (alongside read), and give the container a
durable, writable volume for the write-ahead log — a writer is not the read-only-rootfs
shape a read replica uses. Then you correct, insert and retract over Bolt, live, without
rebuilding the graph.
docker volume create slater-wal # durable — NOT tmpfs
docker run -d --name slater-writer \
-p 7687:7687 \
-v slater-data:/data \
-v slater-wal:/wal \
-v "$PWD/acl.json:/config/acl.json:ro" \
-e delta__enabled=true \
-e delta__walDir=/wal \
hikarisystems/slater:latest
acl.json must grant both permissions to the writer:
{ "users": { "editor": {
"passwordArgon2id": "$argon2id$v=19$...",
"grants": { "people": ["read", "write"] } } } }
Now write over the same Bolt connection any neo4j driver uses:
-- Upsert by business key, then set a property.
MERGE (p:Person {name: 'Dave'}) SET p.age = 33;
-- Batch many rows into one group-committed, fsync-durable write.
UNWIND $rows AS r MERGE (p:Person {name: r.name}) SET p.age = r.age;
-- Retract.
MATCH (p:Person {name: 'Dave'}) DETACH DELETE p;
-- Fold the accumulated delta back into a fresh immutable core (optional; also
-- runs automatically per delta.deltaCorePercent / delta.consolidateWindow).
CALL slater.consolidate();
How it behaves:
SUCCESS returns only after the fsync that makes the write durable. Group your
writes (a write-UNWIND commits one fsync for the whole batch) and they're cheap.count(*),
label/type marginals) stay metadata reads even with writes pending.MERGE / MATCH … SET / DELETE (plus
CREATE, REMOVE, detach delete, relationship writes) keyed on a node's identity property.slater-build and
swapping current atomically — the same content-hash guard as any published generation.Tune it with the delta.* knobs in Configuration below.
Slater reads a baked-in /app/config.json and lets you override any field two
ways, both Docker-friendly:
/sandbox/config.json; it
is deep-merged over the defaults at startup.KEY__sub form (double underscore for
nesting), keys matching the camelCase config. Env wins over files.So these are equivalent ways to set the block-cache budget:
-e cache__blockCacheBytes=536870912 # env override
# …or in /sandbox/config.json: { "cache": { "blockCacheBytes": 536870912 } }
| Setting | Env var | Default | What it does |
|---|---|---|---|
dataBackend.kind | dataBackend__kind | fs | Storage backend: fs (local filesystem), s3 (object store), or gcs (Google Cloud Storage). See Storage backends. |
dataBackend.fs.dir | dataBackend__fs__dir | /data | Root dir of generations (<graph>/<uuid>/) for the fs backend. |
dataBackend.s3.* | dataBackend__s3__* | (empty) | S3 settings: bucket, region, endpoint, prefix, pathStyle, awsAccessKey/awsSecretKey/awsSessionToken (else the standard AWS chain), and diskCacheBytes/diskCacheDir (local-disk second tier). See Storage backends. |
dataBackend.gcs.* | dataBackend__gcs__* | (empty) | GCS settings: bucket, prefix, endpoint, credentialsPath/credentialsJson (else ADC), anonymous (emulator only), and diskCacheBytes/diskCacheDir. See Storage backends. |
aclPath | aclPath | /config/acl.json | Path to the ACL file. |
server.bind | server__bind | 0.0.0.0 | Bind address. |
server.port | server__port | 7687 | Bolt port. |
cache.blockCacheBytes | cache__blockCacheBytes | 67108864 (64 MiB) | Decompressed graph-block LRU. |
cache.vectorCacheBytes | cache__vectorCacheBytes | 67108864 (64 MiB) | Resident PQ codes + Vamana block LRU. A cap, not a reservation — empty without vector indexes. |
cache.resultCacheBytes | cache__resultCacheBytes | 16777216 (16 MiB) | Query-result LRU. |
cache.cacheTtlMs | cache__cacheTtlMs | 1800000 (30 min) | Idle TTL: a cached entry untouched for this long is reclaimed by a background sweep, freeing memory below the budgets. A negative value (or 0) disables the sweep. |
query.maxRows | query__maxRows | 100000 | Max rows per result. |
query.timeoutMs | query__timeoutMs | 30000 | Per-query timeout (0 = none). |
vectorQuery.beamWidth | vectorQuery__beamWidth | 64 | Vamana beam-search width. |
generationPollMs | generationPollMs | 5000 | How often to poll each graph's current pointer. |
reloadStrategy | reloadStrategy | exit | On a generation change: exit (let the orchestrator restart) or swap (hot-swap in place). |
cacheWarmingQuery | cacheWarmingQuery | (empty) | Cypher run once at boot per graph (results discarded) to pre-fault its blocks into the caches so the first matching client query is warm. Empty = off. |
delta.enabled | delta__enabled | false | Master switch for the writable layer. Off ⇒ pure immutable core; writes refused. See Writing to a graph. |
delta.walDir | delta__walDir | wal | Directory for per-graph WAL segments (the durability floor). Must be a durable, writable volume — never tmpfs. Relative paths resolve under the data dir. |
delta.memtableBytes | delta__memtableBytes | 67108864 (64 MiB) | In-RAM active memtable budget before it flushes to an immutable L0 delta segment. |
delta.deltaCorePercent | delta__deltaCorePercent | 0 (off) | Auto-consolidate once the delta reaches this % of the core's entity count (rebuild is O(core) — keep rare; typical 5–25). 0 ⇒ manual/scheduled only. |
delta.consolidateWindow | delta__consolidateWindow | (empty) | Off-peak cron window gating auto-consolidation, e.g. 0 1-5 * * *. Empty ⇒ whenever due. |
delta.deltaHardBytes | delta__deltaHardBytes | 0 (off) | Hard cap on resident delta bytes; a write past it throttles until a consolidation drains — the OOM backstop. |
log.level | log__level | info | Log level. |
Example — a memory-tight server with a 10-minute idle TTL:
docker run -d --name slater -p 7687:7687 \
-v slater-data:/data:ro -v "$PWD/acl.json:/config/acl.json:ro" \
-e cache__blockCacheBytes=134217728 \
-e cache__vectorCacheBytes=67108864 \
-e cache__cacheTtlMs=600000 \
hikarisystems/slater:latest
Resident memory tracks blockCacheBytes + vectorCacheBytes + resultCacheBytes
to within bounded per-entry and allocator overhead, plus a small fixed overhead
— it does not grow with graph size. The three pools are
isolated on purpose so a vector-heavy query can't evict hot graph blocks (and
vice versa); tune the split with the budgets above.
The idle TTL (cacheTtlMs, default 30 min) reclaims pool memory once the
working set goes quiet, so you can set generous budgets without paying for idle
RAM. It only affects idle time — under concurrent load the budgets still cap RSS.
Watch the pools live over Bolt — SHOW STORAGE INFO appends per-pool metrics
(block_cache_hits / misses / evictions, and the same for vector_cache_* and
result_cache_*). High misses/evictions on one pool while another sits idle means it's
time to rebalance the budgets.
Slater serves the same immutable generation byte-format from either local
storage or an object store — only dataBackend.kind changes, never the data.
fs (default) — generations live under /data (the mounted volume). Right for
almost everyone: build, mount read-only, serve. Nothing else to configure.s3 — generations live in an S3 (or S3-compatible: MinIO/localstack) bucket;
S3 support ships in the image (config-only). Integrity from S3's server-computed
SHA-256 via a metadata request (no body download). Credentials from the standard AWS
chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, or an instance role).gcs — generations live in a Google Cloud Storage bucket (config-only).
Integrity from GCS's server-computed CRC32C via a metadata request. Credentials are
GCP-native: Application Default Credentials, or a service-account JSON key.Use S3 or GCS when you want generations in durable, central object storage that many
stateless replicas can read — publish once, fan out — or to decouple build from serve
hosts. The cost is latency (a cold block is a network round-trip); the in-memory caches
and the local-disk block cache below hide most of it. If your data sits on a fast
local/NFS/EBS volume, fs is simpler and faster.
docker run -d --name slater-s3 -p 7687:7687 \
-v "$PWD/acl.json:/config/acl.json:ro" \
-e dataBackend__kind=s3 \
-e dataBackend__s3__bucket=slater \
-e dataBackend__s3__region=eu-west-2 \
-e AWS_ACCESS_KEY_ID=… -e AWS_SECRET_ACCESS_KEY=… \
hikarisystems/slater:latest
Optional, opt-in: set dataBackend.s3.diskCacheBytes > 0 and mount a writable
volume for diskCacheDir. A block evicted from the in-memory cache is then
served from local SSD (~0.1 ms) instead of a fresh S3 GET — so an S3-backed node
performs close to a local one once warm, and your S3 request count/cost drops.
It caches the bytes exactly as fetched (still compressed and, for encrypted
generations, still sealed — it never holds the key), writes them behind the query
on a background thread, evicts to stay within budget, and self-heals a corrupt
cache file with a checksum verified on every read.
Must be a real writable volume, never
tmpfs. tmpfs is RAM and would break the bounded-memory guarantee. Size the cache ≫blockCacheBytes.
Its RAM cost is only the index that tracks the cached blocks plus the
write-behind queue, which is bounded at blockCacheBytes / 8 (8 MiB by default)
and sheds under pressure instead of growing. Both count against the RSS ceiling;
neither needs configuring — enabling the disk tier does not change how you size
the container.
Add to the S3 docker run above: a writable cache volume and the two flags —
-v slater-s3-cache:/var/cache/slater \
-e dataBackend__s3__diskCacheBytes=10737418240 \
-e dataBackend__s3__diskCacheDir=/var/cache/slater
slater-build writes to --data-dir first, then optionally uploads to a bucket
(the remote current pointer is written last, so a serving node never sees a
half-published generation). The image ships with both the s3 and gcs
backends compiled in, so publishing to either needs no special image — just the
flags.
S3 (credentials via the AWS_* env, an instance role, or
--publish-s3-* flags for an S3-compatible endpoint):
docker run --rm -v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
-e AWS_ACCESS_KEY_ID=… -e AWS_SECRET_ACCESS_KEY=… \
--entrypoint /app/slater-build hikarisystems/slater:latest \
--input /dumps/people.cypher --graph people --data-dir /data \
--publish-s3-bucket slater --publish-s3-region eu-west-2
# MinIO/localstack: add --publish-s3-endpoint http://host:9000 --publish-s3-path-style
GCS — same shape, swapping in --publish-gcs-bucket <b> (plus optional
--publish-gcs-prefix). Credentials use Application Default Credentials, or mount a
service-account key and add --publish-gcs-credentials /secrets/sa.json.
docker run --rm hikarisystems/slater hash-password '<pw>'.grants in acl.json, mount it at /config/acl.json
(or point aclPath elsewhere).Generations can be sealed per-block with XChaCha20-Poly1305. Build encrypted:
docker run --rm \
-v slater-data:/data -v "$PWD/dumps:/dumps:ro" \
-e MASTER_KEY="$(openssl rand -hex 32)" \
--entrypoint /app/slater-build \
hikarisystems/slater:latest \
--input /dumps/people.cypher --graph people --data-dir /data \
--encrypt --key-env MASTER_KEY
Serve it by giving the server the same key — either an env var or a mounted file:
-e encryption__keyEnv=MASTER_KEY -e MASTER_KEY=<hex>
# …or:
-e encryption__keyFile=/run/secrets/slater-key # mount the hex key there
bolt+s://)Mount PEM material and point at it:
-v "$PWD/tls:/sandbox/tls:ro" \
-e tls__cert=/sandbox/tls/server.crt \
-e tls__key=/sandbox/tls/server.key
The container `
Content type
Image
Digest
sha256:24ac1ecfb…
Size
23.7 MB
Last updated
about 2 months ago
docker pull hikarisystems/slater