Service Levels¶
What Crystal guarantees, what it does not, and what it has been measured doing. This page describes the engine's properties. An availability SLA for a deployment is a property of how you run it — how many replicas, on what hardware, behind what ingress — and this page is the input to that calculation rather than a substitute for it.
Figures below were measured on Go 1.26, linux/amd64, using the repository's own benchmark fixture.
Durability¶
| Property | Guarantee |
|---|---|
| Acknowledged write survives process kill | Yes |
| Acknowledged write survives power loss | Yes, subject to the disk honouring fsync |
| Acknowledged write survives loss of the volume | No — replicate or back up |
| Partially applied batch after a crash | Impossible |
| Silent corruption on load | Impossible — refuses to start instead |
A write is acknowledged only after its WAL record is written and fsynced. Snapshots are written to a temp file, fsynced, renamed, and the directory fsynced — because the WAL is truncated immediately after a successful snapshot, a snapshot that silently did not land would take the only other copy with it.
Batches are atomic across a crash as well as within the process: all of a transaction's records are written in one fsync and only the last carries a commit marker, so recovery discards any transaction whose marker never landed.
What is not durable: anything the client did not receive a success response for. A crash mid-write loses that write and only that write — recovery reports the discarded bytes rather than hiding them.
Consistency¶
| Scope | Level |
|---|---|
| Single operation | Linearizable |
Batch (Apply, POST /api/v1/batch) |
Atomic — all or nothing |
Read inside View |
Snapshot isolation |
| Traversal, query, neighbour lookup | Snapshot isolation (they run inside a view) |
| Read on the primary after a write on the primary | Read-your-writes |
| Read on a follower after a write on the primary | No guarantee — asynchronous |
All writes serialise through a single lock, so there is one global order and no write-write conflicts to resolve. Reads inside a view see one instant of the graph: an operation assembled from several reads can never observe an edge whose endpoint was deleted between two of them.
Referential integrity is enforced on write — edge endpoints must exist, and vertex deletion cascades inside the same transaction. Data written by older builds is checked at startup and reported, not silently trusted.
What is not offered: interactive read-write transactions (Begin/Commit),
multi-graph transactions, and any cross-node consistency. Replication is
asynchronous with no consensus.
Availability¶
Crystal is a single process per graph. Its availability is what you build around it.
| Failure | Effect |
|---|---|
| Process restart | Unavailable for the duration of startup, which is O(graph) |
| Storage volume lost | Graph lost unless replicated or backed up |
| One graph unhealthy | The whole node reports not-ready |
| Follower fails | No effect on the primary |
| Primary fails | Writes unavailable until an operator promotes a follower |
Startup reads the snapshot, replays the WAL and rebuilds the adjacency index, so
restart time grows with graph size. Bound WAL replay with max_wal_bytes; the
snapshot load itself is proportional to the graph.
Readiness (/readyz) covers every graph the process serves, so one broken graph
depools the instance — it cannot serve the routes addressing that graph, and
there is no way to depool a subset of an instance's routes. Liveness (/livez)
deliberately ignores storage, because restarting does not fix a full disk.
Failover is manual. See the cluster guide.
Capacity¶
The binding limit is RAM, not disk.
| Marginal cost | |
|---|---|
| Per vertex | 745 bytes |
| Per edge | 626 bytes |
Budget RAM at 2× that for garbage-collector headroom.
| RAM | Graph at benchmark density (|E| ≈ 10·|V|) |
|---|---|
| 4 GB | ~300k vertices / ~3M edges |
| 16 GB | ~1.2M vertices / ~12M edges |
| 64 GB | ~4.9M vertices / ~49M edges |
Supported envelope: ~1M vertices / ~10M edges on a 16 GB host. Past that the engine runs but nothing is designed or benchmarked for it.
Property payload dominates these numbers, not graph structure — measure at your own density.
Exceeding RAM is currently unrecoverable
A graph that outgrows its host is OOM-killed, and because startup loads the
whole graph, the restart is killed too. There is no degraded mode and no way
back in through Crystal. Size with headroom and alert on
/api/v1/diagnostics memory well before the limit. Fixing this is tracked
as an epic (admission control, capacity checks at startup, and a maintenance
mode that can export or prune without loading the graph).
Performance¶
Measured, single node, 100k vertices / 1M edges unless stated.
| Operation | Observed |
|---|---|
| Get vertex by ID | ~1 µs |
| Get edges by vertex | ~10 µs |
| BFS depth 3 | ~2 ms |
| BFS depth 5 | ~84 ms |
| Query by type, limit 100 | ~10 ms |
| Property-filter query (full scan) | ~65 ms |
| Read during a concurrent checkpoint (40k vertices) | 7–39 ms worst case |
Reads are map lookups; nothing touches disk on the read path. Writes cost one
fsync each — that is the floor, and it is why batching several mutations into
one Apply is dramatically cheaper than issuing them separately.
Known slow paths, all bounded but all O(N):
- Queries filter by scanning. There are no property indexes yet, so a
PropertyEqualsover 100k vertices visits all of them. GetVerticesByType/GetEdgesByTypescan the full map.- Pagination bounds the response, not the scan.
Bounded by configuration, not by luck: traversal depth, path enumeration
depth, result count and wall-clock timeout all have ceilings, and requests
asking for more are clamped with the response flagged truncated.
Operational limits¶
| Limit | Default | Setting |
|---|---|---|
| Request body | 4 MiB | server.max_body_bytes |
| Traversal depth | 20 | traversal.max_depth |
| Path enumeration depth | 6 | traversal.max_path_depth |
| Paths returned | 1000 | traversal.max_path_results |
| Path search wall clock | 5s | traversal.path_timeout |
| WAL size before checkpoint | 64 MiB | storage.max_wal_bytes |
| Checkpoint interval | 30s | storage.flush_interval |
| Graphs per process | 32 | storage.max_graphs |
| Graceful shutdown | 15s | server.shutdown_timeout |
Security posture¶
- Authentication is required by default; the server refuses to start with it enabled and no tokens.
- Tokens carry
readorwritescope. Scope is per process, not per graph — one Crystal process is one trust boundary. - CORS is closed by default; a wildcard origin is rejected while auth is on.
- TLS is supported natively or can be terminated upstream.
- Health probes are public by design; runtime diagnostics are authenticated.
Recovery objectives¶
These follow from the above rather than being promises the engine makes on its own — they are what a deployment can achieve.
| Objective | Single node | With a follower |
|---|---|---|
| RPO (data loss) | 0 for acknowledged writes, unless the volume is lost | replication lag, typically sub-second |
| RTO (time to serve) | process restart + O(graph) load | manual promotion, minutes |
To achieve RPO 0 against volume loss you need synchronous replication, which
Crystal does not offer. Back up graph.json — it is a plain, self-describing,
versioned file, and a checkpoint makes it current.