Running Crystal in Cluster Mode¶
Crystal is RAM-resident: one process holds its whole graph in memory, and disk provides durability rather than capacity (see ADR 0001). Scaling out therefore means running more processes, and there are two ways to do it that solve different problems.
| Mechanism | Scales | Does not scale |
|---|---|---|
| Sharding by graph | capacity, write throughput | a single graph |
| Read replicas | read throughput, availability | capacity |
They compose. A typical production layout uses both.
One graph must fit on one node
Crystal does not distribute a single graph across machines, and this is a deliberate decision rather than a missing feature. Traversal is the primary operation and is pointer-chasing by nature; every cross-partition hop would become a network round trip, so a depth-5 traversal across shards would be orders of magnitude slower than in-process. If you genuinely need one graph larger than one machine, the answer is a bigger machine, not more of them.
Sharding by graph¶
One process serves several named graphs, and graphs are completely isolated — no query, traversal or consistency check crosses between them. Route each graph to whichever node has room.
flowchart LR
C[Clients] --> R[Router / ingress]
R -->|case-a, case-b| N1[crystal-1]
R -->|case-c| N2[crystal-2]
R -->|case-d, case-e| N3[crystal-3]
This fits investigation and narrative workloads almost exactly, because those are naturally partitioned: one investigation, one case or one story is one graph. Capacity then scales with node count.
Creating a graph¶
curl -XPOST -H "Authorization: Bearer $CRYSTAL_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"case-2024-07"}' \
https://crystal-1:7540/api/v1/graphs
Every graph-scoped endpoint then exists in two forms:
# the default graph
curl -H "Authorization: Bearer $CRYSTAL_TOKEN" \
https://crystal-1:7540/api/v1/vertices
# a named graph
curl -H "Authorization: Bearer $CRYSTAL_TOKEN" \
https://crystal-1:7540/api/v1/graphs/case-2024-07/vertices
Names must match ^[a-z0-9][a-z0-9_-]{0,62}$.
Sizing a node¶
Every graph on a node is resident in full, so a node's memory cost is the sum of its graphs:
and budget RAM at roughly twice that for garbage-collector headroom. Set
storage.max_graphs per node — without it, the create-graph API is a way to
walk into the memory limit one namespace at a time.
Creating a graph past the limit returns 507 Insufficient Storage, not 400: the request is well formed, the server is out of room.
Routing¶
Crystal does not route between nodes; the graph-to-node mapping lives in your ingress. Because the graph name is in the path, ordinary path-prefix routing is enough:
location ~ ^/api/v1/graphs/(case-a|case-b)/ { proxy_pass http://crystal-1:7540; }
location ~ ^/api/v1/graphs/(case-c)/ { proxy_pass http://crystal-2:7540; }
Keep the mapping in one place and treat it as configuration. Moving a graph between nodes is an export, an import and a routing change — there is no automatic rebalancing.
On isolation¶
Graph isolation is enforced in the storage layer, so it holds for traversals and
queries as well as direct reads. Authentication, however, is not yet
per-graph: a read token reads every graph on the node it can reach. Until
that lands, treat one Crystal process as one trust boundary and run separate
processes for tenants that must not see each other.
Read replicas¶
A follower consumes the primary's transaction stream and serves reads.
flowchart LR
W[Writers] --> P[(primary)]
P -.->|WAL stream| R1[(replica 1)]
P -.->|WAL stream| R2[(replica 2)]
RD[Readers] --> R1
RD --> R2
Starting a follower¶
export CRYSTAL_AUTH_TOKEN=$(openssl rand -hex 32) # this node's own tokens
export CRYSTAL_REPLICATION_TOKEN=<read token on the primary>
./crystal --data-dir /var/lib/crystal-replica \
--replica-of https://primary:7540
The replication token only needs read scope — replication reads the primary, it does not write to it.
To follow one named graph rather than the primary's default, set
replication.graph in the config file.
What a follower does¶
On start it compares its own log position with the primary's:
- empty store — bootstraps from
GET /replication/snapshot, a consistent full copy plus the LSN describing it - existing store — resumes from its own persisted LSN, so a restart does not re-copy the graph
- too far behind — if the primary has already checkpointed away the transactions it needs, it re-bootstraps automatically
It then holds GET /replication/stream open and applies transactions as they
commit.
Followers refuse writes¶
Every mutating request to a follower returns 403. This is not a safety rail that can be turned off: a follower's data is defined entirely by the primary's stream, so a local write would either be silently overwritten by the next transaction touching the same entity, or survive as divergence that nothing ever reconciles.
Monitoring replication¶
curl -H "Authorization: Bearer $TOKEN" https://primary:7540/api/v1/replication/status
{"lsn":4210,"snapshot_lsn":4096}
Compare lsn on the primary with lsn on each follower; the difference is the
lag in transactions. A follower whose lag is growing is either under-resourced
or losing its connection — check its logs for reconnection messages.
snapshot_lsn is the oldest transaction still in the primary's log. A follower
whose position falls below it must re-bootstrap, which for a large graph is
expensive. Widen that window on the primary by raising max_wal_bytes and
flush_interval.
Consistency model¶
Replication is asynchronous.
- A follower lags the primary by however long it takes to ship and apply the last transaction.
- Read-your-writes holds on the primary only. A client that writes to the primary and immediately reads from a follower may not see its own write. If your application needs that guarantee, route those reads to the primary.
- Each follower applies transactions in the primary's order, so a follower is always a consistent past state of the graph — never a mixture.
- There is no consensus and no automatic failover.
Failover is manual, on purpose¶
Crystal will not promote a follower by itself. Automatic promotion without consensus is how split-brain happens: two nodes both believing they are primary, both accepting writes, with no way to reconcile afterwards. Refusing to offer failover is better than offering one that can lose acknowledged writes.
Promotion runbook
- Stop writes to the old primary. If it is reachable, stop the process. If it is not, make certain it cannot come back and receive traffic — a fenced node that returns later and accepts writes is the failure this step exists to prevent.
- Pick the follower with the highest LSN (
/api/v1/replication/status). Any transactions the old primary committed above that LSN are lost; this is the cost of asynchronous replication and the number to record in your incident notes. - Restart it without
--replica-of. It becomes writable. - Repoint clients and the remaining followers at the new primary.
- Rebuild the old node as a follower when it returns. Do not restart it as a primary — its log has diverged.
Combining both¶
For a large multi-tenant deployment, shard by graph and replicate each shard:
flowchart TB
subgraph S1["Shard 1 — case-a, case-b"]
P1[(primary)] -.-> R1[(replica)]
end
subgraph S2["Shard 2 — case-c, case-d"]
P2[(primary)] -.-> R2[(replica)]
end
I[Ingress] --> P1
I --> R1
I --> P2
I --> R2
Route writes for a graph to its shard's primary and reads to either the primary or its replicas. Note that a follower replicates one graph, so a sharded node hosting several graphs needs one follower process per graph it replicates. That is a real limitation of the current implementation and worth weighing when choosing how many graphs to put on a node.
What is not solved¶
- Capacity for a single graph — bounded by one machine's RAM.
- Automatic failover — manual, see above.
- Rebalancing — moving a graph between nodes is manual.
- Per-graph authentication — tokens are per-process, not per-graph.
- Cross-graph queries — a query addresses exactly one graph.
See the service levels page for what is and is not guaranteed.