Configuration
Three sources, and a command line flag beats an environment variable beats a default. The flag wins so that a shell
which exported GRAPH_ALLOW_WRITE for one server cannot silently change what a second one started in the same shell
does. The environment exists because a container image cannot be reconfigured without rebuilding its CMD, and a
Helm chart has environment variables and not an argument vector.
| Variable | Flag | Default |
|---|---|---|
|
(first positional) |
required |
|
(second positional) |
8080 |
|
|
|
|
|
false |
|
|
false |
|
|
false |
|
|
small |
|
|
30000 |
|
|
1000 |
|
|
true |
|
|
8081 |
|
|
127.0.0.1 |
|
|
8 |
|
|
true |
|
|
8082 |
|
|
false |
|
|
8083 |
|
|
127.0.0.1 |
Three behaviours that are decisions rather than accidents:
-
An empty variable is unset, not empty.
GRAPH_DATABASE=""is exactly how a Helm template renders a value nobody set, and treating it as a real setting would turn an omitted chart value into an empty database name. -
A boolean typo is refused.
GRAPH_ALLOW_WRITE=tureis a start-up error naming the variable, rather than a read-only server with no explanation. -
Two fixed ports on one number are refused at parse time, across all four listeners — rather than surfacing as "address already in use" from whichever bound second.
Sizing
A hundred million nodes with two properties each and an average degree of ten is roughly 27 GB, plus the log:
Node with two small properties |
128 bytes |
Secondary index entries for the same node |
~133 bytes |
Directed adjacency entry |
1.0 to 1.2 bytes |
Relationship record (only for edges with properties) |
64 bytes |
Two multipliers to plan for. Segment metadata is sized for the segment, not its contents — about 6 MB per segment file whatever is in it, though the files are sparse. And a transaction is bounded by heap, because the engine is no-steal: a bulk load touching a gigabyte of pages needs a gigabyte. Split large loads into batches.
The one tuning decision that matters most is not a setting: durable commits run at roughly 750 to 1000 per second, because each one is an fsync. Batching a load into a single transaction is nearly three orders of magnitude faster.
The slow query log
A histogram says how often and how bad. It cannot say which query — a query string cannot be a Prometheus label without producing one time series per distinct query. That is what this is for, and the two are meant to be read together with the metrics.
Slow query: 1420 ms, 12 rows, outcome ok, http front end, database 'default', params [city, since] — MATCH (p:Person)...
Four decisions in that line:
-
Parameter values are never logged.
MATCH (u:User {token: $t})puts the secret in the parameter map rather than in the text, and this is the file most likely to be shipped whole to an aggregator that a different set of people can read. The parameter names are logged, because they are schema rather than data. -
The text is collapsed to one line and truncated at 500 characters, so a generated query cannot fill a disk.
-
Failed and abandoned queries are logged too. A query that hit its timeout is the most interesting line in this log: it cost the most and returned nothing.
-
Set it below the query timeout, or nothing can ever be logged — the query is abandoned first. The server warns at start-up if the two are configured that way.
Shutting down
The order is a safety property rather than politeness. A store’s segments are memory-mapped, and closing one unmaps them, so a worker still inside a query at that moment would be reading an address the process no longer owns.
New queries are refused first, running ones are given a grace period, and only then are the stores closed. Nothing committed is at risk regardless — the log is fsynced before any data page is touched, so a write interrupted at any instant either committed or did not.
Security posture
There is no authentication or transport security on any port. This is stated rather than mitigated:
| Port | Binds | Notes |
|---|---|---|
HTTP 8080 |
all interfaces |
Browser UI and |
Binary 8081 |
loopback |
Streaming Cypher; loopback because a new unauthenticated port should not default to every interface |
Monitoring 8082 |
all interfaces |
|
MCP 8083 |
loopback, off |
A remote code path an LLM drives |
Anything that can reach the HTTP or binary port can read every byte of every database under the root. Treat exposing either as equivalent to handing out the root directory, because that is what it is.
--allow-write bounds what a peer can do, not who the peer is. It is a posture, not access control.
What is not here
-
No online backup, no incremental backup, no point-in-time recovery. A cold copy of a stopped store is the only correct procedure today, and
cp -a --sparse=alwaysmatters — hole punching leaves large sparse regions. -
No repair tool for a store that fails
verify. -
No background maintenance. Checkpoint, reclaim and compact are calls the embedder schedules.
-
No multi-writer concurrency, no replication, no failover.