Slater

CI

Release

Current version: v0.24.1 — all releases.

all releases

In one line: Slater serves graphs that don't fit in memory — hundreds of millions of nodes and billions of edges in low hundreds of MB of RAM — over standard Bolt, so any neo4j driver just works, with disk-native vector search sitting next to the graph, and it takes live, durable writes without giving that up. Resident memory is set by a cache budget you choose, not by the size of the graph.

Shortcuts

Why Slater exists

Reads and writes

What you get

Features

How it works

The writable layer

Storage backends

Mounts

ACL

Health check

Worked example

Development

Performance

License

📖 Full manual

Why Slater exists

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.

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.

Slater 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.

Slater takes the opposite approach. You compile the graph once, offline, into a content-addressed on-disk image with slater-build; 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.

That 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.

Reads and writes

Slater 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.

Writes 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:

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(), the label and relationship-type marginals — stay metadata reads even with writes outstanding: the delta keeps its own counters, so a count() over 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 only after the fsync that covers the write. Group your writes and they're cheap — a write-UNWIND commits one fsync per batch rather than per row.

Business-key writes, in either dialect. MERGE / MATCH … SET / DELETE (and CREATE / REMOVE, detach delete, relationship writes) keyed on a node's identity property — or the equivalent ISO GQL data-modifying statements (INSERT / SET / REMOVE / DELETE), which lower onto the same path. Correct, insert, upsert and retract, over nodes and edges, addressed the way your data already is.

The writable layer is opt-in (delta.enabled); with it off, Slater serves the pure immutable core and refuses writes. See The writable layer for the full model.

The writable layer

On the name. Slater is named after the CIA agent in Archer (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.

character wiki page

What you get

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 / SET / DELETE over nodes and edges, group-committed and fsync-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 pointer, 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.

Features

Storage backends

Two binaries make up the workspace:

Slater splits bulk building from serving: slater-build does the heavy lifting

offline — ingesting your data and compiling it into an immutable generation — so a

cold graph is never assembled on the serving hot path. Within the server, the read

surface answers a broad Cypher slice — pattern matching, WITH/UNION/CALL {…}

subqueries, 70+ scalar & aggregate functions, temporal & geospatial values, graph

algorithms (algo.*), and disk-native vector KNN (db.idx.vector.queryNodes) —

while the writable layer's delta overlay sits below that surface and is zero-cost

when empty, so reads never carry the write-side machinery. You can update a graph

two ways: write to it live over Bolt (see The writable layer),

or build a new generation offline and atomically swap the current pointer, which

the running server picks up via its generation guard (see

Generation guard).

The writable layer

Generation guard

Documentation

The complete user manual lives in docs/manual/ —

a feature-by-feature guide that explains, for every capability, what it is, why it

exists, and how to use it, with worked examples you can run against a bundled

sample graph. Start there for anything beyond this overview.

docs/manual/

New here? Quickstart builds and serves a

graph in five steps.

Quickstart

Writing queries? Querying,

Functions & expressions,

Procedures & algorithms,

Vector search,

Writing data.

Querying

Functions & expressions

Procedures & algorithms

Vector search

Writing data

Building graphs? Building graphs and the

Build CLI reference.

Building graphs

Build CLI reference

Operating Slater? Deployment,

Storage,

Configuration reference,

Security,

Performance tuning.

Deployment

Storage

Configuration reference

Security

Performance tuning

Running with Docker

Slater is designed to be run as a Docker deployment — that's the expected way

to use it. Prebuilt multi-arch images (linux/amd64 + linux/arm64) are

published to Docker Hub at

hikarisystems/slater,

tagged :latest and :vX.Y.Z on every release:

hikarisystems/slater

A Docker-command-only usage, configuration, and operations guide lives in

DOCKERHUB.md (and is mirrored to the Docker Hub overview page) —

start there if you're deploying. In short:

DOCKERHUB.md

To build the image locally instead (e.g. for development):

The builder stage installs cmake, clang and libclang-dev for the rustls

aws-lc-rs backend; git (already in the base image) is required for the

hs-utils git+tag dependency, which .cargo/config.toml fetches via the git CLI.

The sections below cover the on-disk format, configuration, ACLs, and a

local (non-Docker) worked example.

How it works

A generation is one immutable directory: a MANIFEST.json (symbol tables,

index descriptors, an optional encryption header), columnar block files

(node_props.blk, node_labels.blk, edge_props.blk, topology.csr.blk,

vectors.f32.blk), range indexes (range/<name>.isam), above-threshold ANN

indexes (vector/<label>.<prop>.{vamana,pq}), and a current text pointer.

Every block is zstd-compressed and BLAKE3-checksummed; with --encrypt each

block is additionally sealed with XChaCha20-Poly1305 (AEAD at rest).

The server opens a generation by 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 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.

The writable layer

With delta.enabled, the immutable generation becomes the fully-compacted bottom

level (the "core") of a small log-structured-merge tree, and live writes ride on

top of it:

Durability floor — the WAL. Each mutation is serialised behind a single

writer per graph, appended to a per-graph write-ahead log, and fsynced before

the Bolt SUCCESS is returned — so acknowledged ⇒ durable, and a torn tail is

dropped on replay. A batched write-UNWIND appends its rows and commits one

fsync for 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. Read replicas stay stateless.

Memtable → L0 → consolidation. Writes accumulate in an in-RAM memtable

(bounded by delta.memtableBytes); when it fills it flushes to an immutable L0

delta segment. A consolidation folds {core + delta} into a fresh core by

serialising the merged view back through slater-build and swapping current

atomically — the same content-hash guard as any published generation. Trigger it

by hand with CALL slater.consolidate(), automatically at delta.deltaCorePercent

of the core's size (optionally gated to an off-peak delta.consolidateWindow), or

let the delta.deltaHardBytes throttle backstop runaway growth.

The overlay sits below the read surface. The executor reads through a

ReadView that is either the bare core (delta always empty) or a merged

(core, delta) view; 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(*), 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) tuple 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.

The exact write grammar and knobs are in the Configuration

table (delta.*) and the Worked example below.

Configuration

Worked example

Range indexes (ISAM)

A range index (range/<name>.isam, one per indexed (label, property)) lets a

MATCH (n:Label {prop: v}) or WHERE n.prop <op> v resolve to the matching node

ids without scanning the label. It is an

ISAM (Indexed Sequential Access Method)

structure — the classic 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.

ISAM

Entries (value, entity_id) are sorted by value and packed into the same

zstd-compressed 256 KiB blocks as everything else.

A small 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-indexed lookup is single-digit milliseconds

while the same match on an unindexed property scans the whole label.)

The planner picks it via NodeScan::RangeEq / RangeRange; an unindexed

predicate falls back to a label sweep or full scan, with the executor re-checking

every predicate either way.

Vector search (Vamana + PQ) — cosine, L2 and dot, read and write

Vector KNN (db.idx.vector.queryNodes) runs over cosine, L2, or dot-product (MIPS)

indexes. The base index is built offline with two execution paths, chosen per index by

the --ann-threshold (default 50 000 vectors):

Below the threshold — brute force. The full f32 vectors live in

vectors.f32.blk; 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:

Vamana is the graph index from the

DiskANN line of work: a single proximity graph whose edges are pruned (the

--vamana-r out-degree and --vamana-alpha long-edge factor) so a greedy

beam search — start at the medoid, repeatedly hop toward the query, keeping a

candidate list of width vectorQuery.beamWidth — reaches a node's true

neighbours in few hops, i.e. few random block reads per query. The graph

blocks (vector/<label>.<prop>.vamana) are paged in through the vector cache,

not held wholesale.

Product quantisation (PQ)

compresses each vector into a short code (--pq-subspaces × --pq-bits): 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) 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 pool

pins.

Vamana is the graph index from the

DiskANN line of work: a single proximity graph whose edges are pruned (the

--vamana-r out-degree and --vamana-alpha long-edge factor) so a greedy

beam search — start at the medoid, repeatedly hop toward the query, keeping a

candidate list of width vectorQuery.beamWidth — reaches a node's true

neighbours in few hops, i.e. few random block reads per query. The graph

blocks (vector/<label>.<prop>.vamana) are paged in through the vector cache,

not held wholesale.

Vamana

Product quantisation (PQ)

compresses each vector into a short code (--pq-subspaces × --pq-bits): 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) 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 pool

pins.

Product quantisation (PQ)

Writable embeddings — the vector write ladder (FreshDiskANN-style).

An indexed embedding is a first-class writable value. SET n.embedding = vecf32([…]) (and REMOVE) lands in the

write 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 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 hole: the node stops being returned

but stays a navigational waypoint until a background 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, CALL slater.consolidate()

carries the Vamana by reference — hard-linked, byte-identical — and rewrites only a

small id column, folding vector writes into the base without the O(N·R·L) graph

rebuild. Measured numbers, with caveats, are in the performance report.

FreshDiskANN

performance report

Storage backends (filesystem / S3 / GCS)

Every generation file is opened through an ObjectStore abstraction rather

than std::fs directly, so the same on-disk byte format — blocks, indexes,

manifest, current pointer — 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 (read_exact_at), which map

onto a pread on a local file and an HTTP byte-range request on an object store

— Slater never mmaps, so the explicit, bounded-read model is identical

everywhere.

Three first-class backends, selected by dataBackend.kind. The filesystem is

the simple default; Amazon S3 and Google Cloud Storage are equal, fully

supported object-store backends — the published image ships with both compiled

in, so each is configuration-only, and a generation built once can be served from

any of them (even migrated fs → S3 → GCS) without a rebuild.

Both object stores verify integrity from the checksum the store already

computes and keeps, fetched as object metadata: slater-build sends the

checksum on upload (the store validates the bytes against it and stores it), and

the server reads it back at open and compares it to the manifest — one metadata

request per file, no body download. It is content-grade and identical in spirit

across S3 (SHA-256) and GCS (CRC32C). When an object carries no server-stored

checksum (copied in out-of-band, or uploaded with a different default), the server

re-hashes the object body against the manifest BLAKE3 rather than trusting its

byte length — a requested integrity check is never silently downgraded to a size

comparison. Slater-published generations always carry the checksum, so they stay on

the cheap metadata path.

Filesystem (fs)

The default, rooted at dataBackend.fs.dir. The right choice for most

deployments: a generation on a local SSD (or an NFS/EBS mount) served read-only.

Integrity is a full BLAKE3 re-hash of every file at open.

Amazon S3 (s3)

An S3 or S3-compatible bucket (AWS, MinIO, localstack). Credentials come first

from config (dataBackend.s3.awsAccessKey / awsSecretKey, plus

awsSessionToken for temporary STS credentials) and fall back to the standard AWS

chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env, shared profile, or

instance/IRSA role) when left empty.

Google Cloud Storage (gcs)

A GCS bucket, reached over the JSON API. Authorization is GCP-native: by default

it resolves Application Default Credentials — GKE Workload Identity, the GCE

metadata server, or a gcloud / GOOGLE_APPLICATION_CREDENTIALS key. Set

dataBackend.gcs.credentialsPath (a service-account JSON key file) or inline

credentialsJson for an explicit key. dataBackend.gcs.endpoint points at a

fake-gcs-server emulator, and dataBackend.gcs.anonymous=true enables

unauthenticated access for that emulator only — never against real GCS.

In all cases slater-build writes the finished generation to --data-dir first

(its local staging area) and additionally uploads it to the bucket; the remote

current pointer is written last, so a serving node never sees a half-published

generation.

When to use an object store (S3 or GCS)

Reach for s3 or gcs when you want generations in durable, central object

storage rather than on a node's disk — typically: publish once and fan out to many

stateless, disk-less server replicas that all read the same bucket; decouple the

build host from the serve hosts; or lean on the store's

durability/versioning/lifecycle instead of managing volumes. The trade-off is

latency: a cold block is a network round-trip (~10–50 ms) instead of a local read

(~0.1 ms). Slater hides most of it with the in-memory block cache, concurrent

read-ahead, and the optional disk cache below. If your generations already sit

on fast local storage and you don't need the central-bucket model, fs is simpler

and faster.

Local-disk block cache (object-store second tier)

The in-memory BlockCache is deliberately small (bounded RSS is the headline

guarantee), so on a working set larger than RAM the same blocks would be

re-fetched from the object store on every spill. An optional local-SSD second

cache tier fixes that: a block evicted from RAM is served from local disk

(~0.1 ms) instead of a fresh object GET, surviving in-memory eviction and cutting

object-store request count/cost — bringing an object-store-backed node close to

local-filesystem performance once warm. It is opt-in for both s3 and gcs,

enabled by setting dataBackend.<s3|gcs>.diskCacheBytes > 0 and a writable

diskCacheDir.

It caches the sealed bytes exactly as fetched — already compressed, and

(for --encrypt generations) 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 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 must point at a real writable volume — never tmpfs (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.

The tier's other RAM cost is the write-behind queue, which stages blocks on

their way to disk. It is bounded at blockCacheBytes / 8 (floored by

diskCacheBytes) — 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, so the disk tier

adds no new number to the RSS budget beyond its index.

Mounts

A read replica runs with a read-only root filesystem and a non-root user

(appuser:1000) — everything it needs is mounted read-only. A writer

(delta.enabled) additionally needs one durable, writable volume for its WAL.

Storage backends

Environment / configuration

Config is loaded by the house-standard layered loader: the baked-in config.json,

then /sandbox/config.json deep-merged over it, then KEY__sub environment

overrides (double underscore for nesting; keys match the camelCase config).

Every configuration knob — its camelCase key, the KEY__sub environment override, its default, and what it does — is tabulated in the Configuration reference. The most-tuned knobs are the cache budgets (cache.), the query guards (query.), the connection caps (server.), the storage backend (dataBackend.), and the writable layer (delta.*).

Configuration reference

Resident memory is approximately

blockCacheBytes + vectorCacheBytes + resultCacheBytes + a small fixed overhead

(plus up to degreeColumnBytes for the lazy degree column, once the degree-sum

count(endpoint) fast path is exercised), independent of graph size — that is

the headline guarantee, exercised by the

rss_stays_bounded_under_sustained_knn_load integration test. Per-connection

buffers live outside the cache budgets, so the guarantee holds under adversarial

load only because server.maxConnections bounds how many can exist at once.

Network posture

Slater is a read replica handle; the primary connection-security control is the

network, not the binary. Bind it to a private interface, restrict source ranges at

the network layer (security groups / NetworkPolicy), and — if it faces anything but

trusted clients — front it with a connection-limiting L4 proxy (HAProxy maxconn +

a per-source stick-table, or nftables connlimit + hashlimit). That sits before

the file descriptor is ever handed to the process, so it is the most robust limit.

The in-binary limits above (maxConnections, maxPreAuthConnections,

maxConnectionsPerIp, the differential byte caps, and loginTimeoutMs) are

defence-in-depth: they default on and generous so they are invisible to a

legitimate client population, but they make the bounded-RSS guarantee hold even when

the proxy is forgotten. See docs/HARDENING.md for the full

defensive posture, and THREAT_MODEL.md / SECURITY_WORKLIST.md for the canonical

detail.

docs/HARDENING.md

Generation guard

Slater polls each graph's current pointer every generationPollMs

(poll, not inotify — the data dir may be remote/network storage like NFS,

where filesystem change events are unreliable). When it changes:

reloadStrategy=exit (default): the server logs fatal and exits non-zero so the

orchestrator restarts it cleanly against the new generation.

reloadStrategy=swap: 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.

ACL

acl.json maps users to argon2id password hashes and per-graph read / write

grants. Mint a hash (never store cleartext) with:

A starter acl.json ships at the repo root; its shape is:

users — one entry per login, keyed by username.

users — one entry per login, keyed by username.

passwordArgon2id — the $argon2id$… string from slater hash-password

(never cleartext; the file itself is plain JSON and lives on shared storage).

passwordArgon2id — the $argon2id$… string from slater hash-password

(never cleartext; the file itself is plain JSON and lives on shared storage).

grants — per-graph capability lists. Two permissions are meaningful:

read — query the graph. A graph absent from a user's grants is invisible to them.

write — mutate the graph through the writable layer (delta.enabled): the

MERGE / SET / DELETE statements and CALL slater.consolidate().

They are independent: a read grant confers no write access. Turning the writable

layer on therefore cannot promote your existing readers into writers. A writer needs

both — ["read", "write"] — because resolving a business key to write it is a read.

Unrecognised permission strings are ignored (they grant nothing).

grants — per-graph capability lists. Two permissions are meaningful:

read — query the graph. A graph absent from a user's grants is invisible to them.

write — mutate the graph through the writable layer (delta.enabled): the

MERGE / SET / DELETE statements and CALL slater.consolidate().

They are independent: a read grant confers no write access. Turning the writable

layer on therefore cannot promote your existing readers into writers. A writer needs

both — ["read", "write"] — because resolving a business key to write it is a read.

Unrecognised permission strings are ignored (they grant nothing).

Mount it read-only at the path named by aclPath (default /config/acl.json).

The server reloads it on each generation hot-swap, and the at-rest ACL stamp is

re-checked on every reload (see requireAclStamp).

requireAclStamp

Health check

The slater binary doubles as its own liveness probe: slater healthcheck [host] [port] performs a Bolt handshake (not an HTTP request) against the server and

exits 0 if it negotiates a protocol version, 1 otherwise — defaulting to

localhost and the configured Bolt port. This is what the container

HEALTHCHECK runs, so orchestrators see a truly Bolt-ready server, not just an

open socket:

One-shot query

For scripting, CI checks, and quick lookups, slater query mounts a graph's

current generation, runs a single read-only Cypher query in-process, prints the

result as a JSON object, and exits — no server, no Bolt connection. It honours

the same config as the server (storage backend, encryption key, query budgets):

Nodes and relationships expand to their labels/type and properties. Use -q

when you want machine-parseable output (the result JSON is the only thing on

stdout); omit it for an operator-facing run with logs. Without -q a

metrics-only summary is logged after each run — e.g.

carrying the query cost (elements charged), resultCount, execMs, and

limitRowCount (only when the query specifies a LIMIT) — never the query text

or any result value. Exit status is 0 on success, 1 on a parse/open/execute

error (message on stderr).

Export a graph (slater dump)

slater dump exports a graph from a running server as business-key MERGE

Cypher — the same dialect slater-build ingests — so a graph round-trips

(dump → slater-build → new generation) for migration or text backup. Unlike

slater query, it connects over Bolt, authenticates, and honours per-graph

ACLs, so it needs no disk access to the server. The password is read from

SLATER_DUMP_PASSWORD or stdin (never a flag, keeping it out of ps/history).

Each label's identity key is the property carried by its range index; override

with --key Label=prop (repeatable) or a global --pk <field>. CREATE INDEX

DDL is emitted first so the rebuild recreates the indexes. A multi-label node

keeps every label — it is emitted as MERGE (n:Ident:Other {key: v}), with

the identity label (the one supplying the business key) first and the rest

sorted; the merge is keyed on the identity label alone, so the trailing labels

are written onto the node without creating another one. Labels, relationship

types and property keys containing special characters are backtick-quoted on

emission, so unusual names round-trip faithfully and cannot inject Cypher into the

rebuild. Vectors (and other

values with no Cypher-literal spelling) cannot ride a MERGE dump and are dropped

with a warning on stderr. Exit status is 0 on success, 1 on error.

Worked example

A 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 Writing data pages, using the bundled sample graph in docs/manual/examples/.

Quickstart

Writing data

docs/manual/examples/

Development

Object-store backends are opt-in cargo features

A plain cargo build produces a filesystem-only binary — the s3 and gcs

backends are gated behind cargo features so the default build stays small (no AWS

or Google SDK, no async runtime). Enable whichever you need on both slater

(serve) and slater-build (publish):

Each crate exposes matching s3 / gcs features that forward to

graph-format/{s3,gcs}. Requesting a backend at runtime

(dataBackend.kind=s3|gcs, or slater-build --publish-{s3,gcs}-*) without its

feature compiled in fails fast with a clear "built without the … feature" error.

The published Docker image enables both (Dockerfile CARGO_FEATURES), so

prebuilt images need no extra flags — this only matters when building from source.

The integration tests are likewise gated: --features s3 --test s3_minio,

--features gcs --test gcs_emulator (a fake-gcs-server), and --features gcs --test gcs_real (real GCS via ADC); each skips unless its SLATER_* env vars are

set.

See docs/PLAN.md, docs/PROGRESS.md and docs/DECISIONS.md for the design,

the milestone ledger, and the decision log.

Performance

Up to six engines, one single-client suite, graphs from a 62k-node toy to Wikidata

91.6M nodes / 1.5B edges. Each engine is measured in isolation (every other container

stopped — RSS and latency are its own footprint). The latency tables below were

re-measured on Slater 0.21.0 (the writeable build): the small/medium graphs (MeSH, EU-AI-Act)

freshly, and the 91.6M graph as a fresh same-box, shared-anchor slater-vs-Neo4j pass (see

that table). The resident-memory figures carry forward from the earlier pass (measured via

container cgroup; the read path is byte-identical with the writable layer idle). The other

engines' numbers are the established cross-engine run (their versions/performance are unchanged).

All figures are medians (ms) or peak resident memory (MiB). Lower is better everywhere; bold =

best in row. slater was run on its local-filesystem (fs) backend; the S3 and GCS backends trade local-read

latency for object-store round-trips (mitigated by the in-memory caches and the optional local-disk

cache tier), so these figures characterise the engine, not a network-storage deployment.

The three engines that page from disk — slater, Neo4j 5, and LadybugDB — load all five

graphs. The in-memory trio (Memgraph · FalkorDB · ArcadeDB) cannot hold the 1.5B edge graph at

all (it needs ~64–128 GiB resident), and ArcadeDB's importer can't finish it either.

Resident memory (MiB) — bounded as the graph grows ~1,500×

Each figure is committed working memory — what the OS cannot reclaim. Every engine except

slater holds its graph in committed anonymous memory (own heap, Neo4j's off-heap page cache, or

a buffer pool), so its peak RSS is its committed footprint. slater alone serves from the

reclaimable OS page cache of its on-disk store, so its figure is the anon working set; the

store's page cache (evictable under pressure — slater keeps serving) is excluded, and shown as

total in parentheses for the 91.6M graph. Bold = lowest.

slater is the lowest at every scale and grows ~50× while the graph grows ~1,500× — its

footprint tracks the query working set, not the graph (idle ~16–71 MiB throughout). The

in-memory trio grows ~linearly and can't load the 1.5B graph; Neo4j commits a ~2 GiB heap

regardless of query. († LadybugDB on the bounded shapes only — its hub / var-length /

shortestPath traversals at 1.5B edges need its read pool raised to ≥2 GiB, vs slater's automatic

maxIntermediate cap.) The build-time value→count histograms add negligible resident memory —

a few KB for a low-cardinality indexed column, and zero for unique-key graphs like Wikidata

(wikidata_id exceeds the histogram cardinality cap, so none is stored) — so these figures are

unchanged by that feature.

Latency (median ms) — graph fits in RAM (MeSH, 341k / 469k)

slater owns the metadata / index / scan shapes (count, label, idx-eq, scan — ~0.4 ms, 10–200× the

service engines), the indexed point lookup (0.43 ms, now edging the in-memory pair's 0.48 ms),

the unanchored multi-hop (2-hop 1.40 ms via the relationship-type scan, fastest in the field),

and — via a build-time value→count histogram on the indexed grouping key — the whole-label

group-by / count(DISTINCT) (0.45 ms, ahead of LadybugDB's columnar 5.3 ms). The in-memory servers

keep only raw 1-hop (Memgraph 1.21 ms vs slater's 1.28 ms). (pole 62k/106k looks the same:

slater sole-fastest on count/scan ~0.4 ms, ~1.3–2.6 ms on hops.)

Latency (median ms) — vectors (EU-AI-Act kNN, 15k × 1024-dim)

slater answers kNN with an exact brute-force scan (these sets are below its 50k-vector

ANN threshold) where the others use an approximate resident HNSW — so slater's results are

exact (recall 1.0). A SIMD distance kernel + a resident, pre-normalised vector matrix

took Concept from ~23 → ~2.9 ms and Chunk from ~10 → ~2.4 ms, so slater now beats

Neo4j and LadybugDB and is within ~1.4× of Memgraph, trailing only FalkorDB — while exact.

Vector write ladder — insert / update / delete without a rebuild

The tables above are cross-engine read comparisons. The vector write path (the

FreshDiskANN-style write ladder over the static Vamana

base) has no cross-engine counterpart — no other engine here does disk-native, writable

ANN — so the numbers below are single-engine component benchmarks over a synthetic,

embedding-like fixture (a low-rank manifold, dim 768, unequal norms), committed under

crates/slater/benches/ and written up in full — with the

methodology and every caveat — in docs/PERF-REPORT.md. Recall is

always measured against an 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.

FreshDiskANN

crates/slater/benches/

docs/PERF-REPORT.md

The one figure that wants the dedicated perf box is the slow-path consolidation rewrite

throughput — when a consolidation carries deletes or new vectors rather than a pure

permutation, it is a sequential recompress bound by single-thread zstd and local disk, so

the absolute MiB/s is environment-specific (the report shows the shape and explains the

environmental range).

Latency (median ms) — graph ≫ RAM (Wikidata 91.6M / 1.5B)

The in-memory engines (Memgraph / FalkorDB / ArcadeDB) cannot load this graph at all

(~64–128 GiB resident). Only slater and Neo4j 5 do. This is a fresh same-box, same-day pass

against a shared, fixed anchor set — every query hits the identical nodes on both engines,

so the head-to-head is apples-to-apples (a common wikidata_id pool of moderate-degree anchors;

see the note below on why that matters). slater is shown at both fanouts (query.maxFanout 1 =

throughput default, 8 = the latency dial that overlaps cold block reads). Bold = best in row.

The honest picture: slater dominates the metadata / index shapes — count(*) is

metadata-served (0.41 ms vs Neo4j's 3.6 s disk scan, ~8800×), and point-lookup / degree /

3-hop run ~2–10× faster — is even with Neo4j on 1–2-hop (fanout 8 pulls ahead on cold reads),

but loses var-length *1..2 distinct decisively (≈1 s vs Neo4j's 47 ms): slater's

variable-length distinct expansion is materially slower here, a real weakness worth its own

investigation. All of that at a few hundred MB of RSS versus Neo4j's committed ~2 GiB heap.

On the anchors. These traversal numbers depend heavily on which nodes 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's own "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.)

Multi-hop count(*) — memory decoupled from result size

Uncapped multi-hop RETURN count(*) counts during expansion instead of materialising

the matched rows. Same hub anchors on the 91.6M graph, maxIntermediate=20M:

The count holds O(1) rows. Charging is unchanged, so a mega-hub count still trips

maxIntermediate on compute (adjacency reads), bounded as before.

Per-query parallelism (maxFanout)

Raising query.maxFanout overlaps a query's cold, I/O-bound block reads across cores —

it helps large-cold-working-set disk-bound shapes and is flat on warm shapes. On the 1.5B

graph: shortestPath ≤6 918 → 608 ms (1.5×, largest search 6,269 → 2,350 ms, 2.7×);

3-hop count 547 → 298 ms. maxFanout=1 is the default (throughput-oriented); 8 is the

latency dial, at more transient worker memory.

Where slater wins / trails

Full per-engine tables (pole, MeSH, EU-AI-Act + the blockCacheBytes RAM↔latency dial,

Wikidata 1M & 91.6M) are in

perf/cross-engine-hs/README.md; the fresh slater-only pass

(both fanouts, every dataset) is in perf/PERF_CURRENT_STATUS.md.

perf/cross-engine-hs/README.md

perf/PERF_CURRENT_STATUS.md

Concurrency & brown-out (load testing)

The benchmarks above are single-client. The complementary axis — behaviour under many

concurrent clients — has its own harness, perf/loadtest/: a Locust

driver over Bolt plus a coordinator that ramps load, reads CALL slater.diagnostics(),

finds the capacity knee, and names the limiter (full method in

docs/LOAD-TESTING.md). Headlines from a 256 MiB-cache run on the

Wikidata-1M graph (one 16-core box):

perf/loadtest/

docs/LOAD-TESTING.md

Both memory issues the load test surfaced are now closed; all tracked in the load-testing doc.

License

Licensed under the Apache License, Version 2.0. See LICENSE for the

full text and NOTICE 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.

LICENSE

NOTICE

SPDX-License-Identifier: Apache-2.0