Skip to Content
We are live but in Staging 🎉

Graph DDL

Preview — graph DDL is v1. CREATE GRAPH accepts exactly one NODES and one EDGES source 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 GRAPHS
  • KEY defaults to id when omitted. SRC and DST are 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, and DST columns 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.
  • SRC and DST must 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 src doubles 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 synthetic edge_id INT PRIMARY KEY; the graph build reads only SRC/DST and 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:

  1. Validates the spec (tables exist, columns are integers).
  2. Registers a catalog entry in state building (visible to SHOW GRAPHS immediately).
  3. Drains the edge table to completion, then reads (src, dst) at that pinned version — a build never reads mid-drain state.
  4. 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):

columnmeaning
namegraph name
statebuilding · ready · failed
nodes / keynode table · key column
edgesedge table
node_count / edge_countcounts at the last successful build
built_at_versionthe table version the artifact is pinned at
errorbuild 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 doEventual read (default)'strong' read
INSERT INTO knows … (not yet drained)not visible — the pinned CSR doesn’t changevisible — the un-drained write log is merged into the traversal
Edge table drains / compactsthe graph rebuilds automatically on the drain event and re-pins; visible after the rebuildvisible throughout
DELETE FROM knows WHERE …not visible until rebuildvisible (the edge is tombstoned out of the traversal)
Brand-new node key written after the pinunknown node → empty resultreachable — 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_bfs always 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 lazily

LOAD GRAPH returns (graph, action, node_count, edge_count, replicas, served_by); RELEASE GRAPH returns (graph, action, replicas, served_by). action is one of:

actionmeaning
loadedpinned on at least one replica by this call
already_residentevery replica already held it
no_residencyno graph node in the ring — nothing to pin. Serving still works; every query falls back to a per-call transient load
releasedevicted from at least one replica
not_residentnothing 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_PRECONDITION telling you to add a LIMIT — 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 (0 disables 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 inline VALUES relation. Past it, run the bare call.
  • Per-query serve timeout: 60s by default (TABLES_GRAPH_SERVE_TIMEOUT_SECS), surfaced as DEADLINE_EXCEEDED. It is an anti-OOM bound, not a latency SLO.
  • Per-vertex expansion cap: TABLES_GRAPH_MAX_EDGES_PER_VERTEX is off by default because it changes results. When an operator enables it, graph_khop / graph_neighbors expand 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