Graph DDL
Preview — graph DDL is v1.
CREATE GRAPHaccepts exactly oneNODESand oneEDGESsource and integer key columns only; multi-table graphs and string node keys are declared follow-ups, not silent widenings.
Graph statements are plane verbs, intercepted before the generic SQL
parser. They run anywhere SQL runs: Execute, the Postgres wire,
dodil data sql.
Grammar
CREATE GRAPH [IF NOT EXISTS] <name>
NODES ( <table> [KEY <col>] )
EDGES ( <table> SRC <col> DST <col> )
DROP GRAPH [IF EXISTS] <name>
SHOW GRAPHSKEYdefaults toidwhen omitted.SRCandDSTare mandatory — there is no default for an edge’s direction.- Identifiers may be double-quoted (case preserved).
- v1 accepts exactly one NODES source and one EDGES source. The grammar parses comma-separated lists (the catalog is multi-table-ready), but a multi-table spec is rejected loudly at CREATE time.
- The
KEY,SRC, andDSTcolumns must all be integer columns — verified against the table schemas at CREATE time. String node keys are a declared follow-up, not a silent widening. SRCandDSTmust be different columns — an edge table naming the same column twice is rejected at parse time.- The same table can serve as both node and edge source (e.g. an edge list
whose
srcdoubles as the node key). - One statement per call: text after the terminating
;is a loud parse error, not a second statement.
CREATE GRAPH social NODES (person KEY id) EDGES (knows SRC src DST dst);Edge tables need their own primary key. Every write in this plane is keyed, and a multigraph allows duplicate
(src, dst)pairs — so the pair can’t be the PK. Add a syntheticedge_id INT PRIMARY KEY; the graph build reads onlySRC/DSTand ignores it. Extra columns (since, weights, …) are welcome — the build ignores them too, and you join them back at query time in SQL.
Build lifecycle
CREATE GRAPH is synchronous and does real work:
- Validates the spec (tables exist, columns are integers).
- Registers a catalog entry in state
building(visible toSHOW GRAPHSimmediately). - Drains the edge table to completion, then reads
(src, dst)at that pinned version — a build never reads mid-drain state. - Builds the CSR artifact, stores it under the bucket, flips the entry to
ready.
On any failure the entry flips to failed with the error recorded. failed
is terminal — there is no automatic retry; run CREATE GRAPH again (after a
DROP GRAPH) to rebuild. A bare re-CREATE over an existing graph is an
already exists error; IF NOT EXISTS makes it a no-op.
DROP GRAPH evicts any resident copy everywhere, then deletes the catalog
entry and every artifact blob. DROP GRAPH IF EXISTS tolerates absence.
SHOW GRAPHS
Returns one row per registered graph, read straight from the registry (so you
can watch building → ready | failed live):
| column | meaning |
|---|---|
name | graph name |
state | building · ready · failed |
nodes / key | node table · key column |
edges | edge table |
node_count / edge_count | counts at the last successful build |
built_at_version | the table version the artifact is pinned at |
error | build error when state = failed, else empty |
A graph that is not ready is absent to the serve path — traversals against
it fail with a precondition error, not empty rows.
How table edits reflect
The artifact is immutable and pinned at built_at_version. After
CREATE GRAPH:
| You do | Eventual read (default) | 'strong' read |
|---|---|---|
INSERT INTO knows … (not yet drained) | not visible — the pinned CSR doesn’t change | visible — the un-drained write log is merged into the traversal |
| Edge table drains / compacts | the graph rebuilds automatically on the drain event and re-pins; visible after the rebuild | visible throughout |
DELETE FROM knows WHERE … | not visible until rebuild | visible (the edge is tombstoned out of the traversal) |
| Brand-new node key written after the pin | unknown node → empty result | reachable — overlay-only nodes participate |
'strong' is the optional trailing freshness argument on the traversal
functions (graph_khop(…, 'strong'), cypher(…, …, 'strong') — see
Cypher & Traversals). It gives
read-your-writes: a row you just inserted participates in the very next
traversal, no drain needed.
Two honest caveats, straight from the engine:
- Analytics are eventual-only.
graph_pagerank/graph_components/graph_bfsalways run over the pinned artifact — there is no'strong'mode for whole-graph algorithms. - Partial parallel-edge deletes (you had N copies of
(src,dst), deleted some but not all, no rebuild yet) can’t be represented by the strong overlay’s all-or-nothing tombstones: reachability stays correct, but parallel-edge counts may over-report until the next rebuild. The query response carries a warning when this happens.
Residency — LOAD / RELEASE GRAPH
Serving normally loads the artifact lazily. Two verbs manage residency
explicitly (the vector LOAD analog):
LOAD GRAPH social; -- eagerly pin the artifact on the serve tier
RELEASE GRAPH social; -- evict; the next query re-loads lazilyLOAD GRAPH returns (graph, action, node_count, edge_count, replicas, served_by); RELEASE GRAPH returns (graph, action, replicas, served_by).
action is one of:
| action | meaning |
|---|---|
loaded | pinned on at least one replica by this call |
already_resident | every replica already held it |
no_residency | no graph node in the ring — nothing to pin. Serving still works; every query falls back to a per-call transient load |
released | evicted from at least one replica |
not_resident | nothing was resident to evict |
LOAD GRAPH on a non-ready graph — or on a name that doesn’t exist — is a
loud precondition error, not a silent no-op. A LOAD that would push the
node past its pin budget (TABLES_GRAPH_PIN_MAX_MB) or its memory watermark
is also refused loudly; lazy reads never hit that path, they just serve cold.
GraphQL exposes the same pair as the loadGraph(name) / releaseGraph(name)
mutations (which route to the residency plane, returning LoadResult /
ReleaseResult).
Ceilings & limits
- Nodes and edges: at most 2³² − 1 of each per graph (dense ids and CSR
offsets are
u32) — exceeding it is a typed build error, never a wrap. - Node keys: 64-bit integers (negative allowed). Strings rejected.
- Result size: a traversal/analytics result larger than 1,000,000 rows
is a loud
FAILED_PRECONDITIONtelling you to add aLIMIT— the BFS bails as the cap is crossed, so an over-cap result is never materialized and never silently truncated. Operator knob:TABLES_GRAPH_MAX_RESULT_ROWS(0disables the cap). - Composed-statement inlining: a graph call that is
JOINed or decorated inside a larger statement is capped at 10,000 rows, because its rows are spliced in as an inlineVALUESrelation. Past it, run the bare call. - Per-query serve timeout: 60s by default
(
TABLES_GRAPH_SERVE_TIMEOUT_SECS), surfaced asDEADLINE_EXCEEDED. It is an anti-OOM bound, not a latency SLO. - Per-vertex expansion cap:
TABLES_GRAPH_MAX_EDGES_PER_VERTEXis off by default because it changes results. When an operator enables it,graph_khop/graph_neighborsexpand at most N neighbours per vertex and the response carries a mandatory truncation warning — reachable nodes may be missing. A truncated result never reads as complete. - Graphs are directed as stored; undirected traversal is the query-time
'both'direction, not a storage mode. - There is no hop-count ceiling of its own — depth is bounded by the result cap and the serve timeout, not a separate maximum.
See also
- Graph → Cypher & Traversals — the
graph_*functions and the openCypher subset that compiles onto them - Graph → Analytics —
graph_pagerank/graph_components/graph_bfs - Tables → Core Concepts — the SQL plane graphs are defined over
- Connect & wire adapters — the Bolt door and credentials