Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

This manual documents patinaDB 0.12.0.

patinaDB is a managed graph database server, deployed as a Docker container. It stores a property graph — vertices (nodes) and directed edges (relationships), each carrying typed properties — on top of an embedded, single-file ACID B-tree storage engine with native cross-tree atomic commits. You query it with a Cypher-like language over your choice of two wire protocols, and every write is recorded as an engram so you can inspect history and travel back in time.

patinaDB (patinadb-raft) is a standalone node that replicates writes with the Raft consensus protocol, hosts multiple databases, and speaks both a JSON REST API and the native Bolt protocol used by Neo4j drivers and the Neo4j Browser. A single node with --bootstrap is a drop-in lightweight server (a “cluster” of one, no consensus-latency penalty for a single voter); add peers to get automatic failover.

What makes patinaDB distinct

  • Versioned by construction. Every mutation is committed as an engram — a git-like commit for your graph. List the history, view a git-style diff of any single change, diff two arbitrary points in time, tag a meaningful state, and run read queries against the past with USE … AS OF. No audit table to bolt on — the history is the database.
  • Provenance, for free. Because the history is a first-class change stream, patinaDB’s Anamnesis system auto-projects it into a queryable PROV graph in a companion database — who / what / when, and optional source / confidence / derived_from at commit, type, attribute, or per-entity granularity, enriched straight from Neo4j transaction metadata. Where Neo4j leaves you to assemble this from APOC triggers or CDC, here it ships in the engine. See Anamnesis.
  • Cypher-compatible. The engine passes ~97% of the openCypher Technology Compatibility Kit (TCK). See Cypher Support for exactly what is and isn’t covered.
  • Neo4j-tooling-compatible. The server’s Bolt endpoint works with the official Neo4j drivers and the Neo4j Browser, so you can point existing tools straight at patinaDB.
  • Search is built in. Full-text search with BM25 ranking and vector/embedding search (an IVF ANN index, cosine & euclidean) ship in the engine — with the same procedure syntax as Neo4j (db.index.fulltext.queryNodes, db.index.vector.queryNodes). No external search service, no separate vector database.
  • Scales down and up. A one-node cluster (quorum of 1) behaves like a plain lightweight server with no consensus latency; add peers for automatic failover (High Availability). Snapshots and time-travel reconstruction stream in O(chunk) memory, so the graph isn’t bounded by RAM.

Who this manual is for

This is the user manual — how to install, query, operate, and integrate patinaDB. It documents every user-facing feature and, just as importantly, its limitations.

A note on scope

patinaDB deliberately implements a subset of Neo4j/Cypher. It is not a drop-in Neo4j replacement for every workload — it targets versioned/auditable graphs and small-to-medium replicated deployments. Where behaviour differs from Neo4j, this manual calls it out explicitly. Read the Limitations chapter before committing to a production workload.

Installation

patinaDB ships as a Docker image whose entrypoint is patinadb-raft, the Raft-replicated server binary (REST + Bolt). You do not need Rust, a compiler, or any build tooling — pull the image and run it. The image is published for both linux/amd64 and linux/arm64 — the same tag resolves to the right architecture on either platform.

Run the server

docker run -p 7687:7687 -p 21001:21001 \
  -v patinadb-data:/data \
  -e PATINADB_AUTH_PASSWORD=change-me \
  patinadb/patinadb

This starts a single self-leading node (a “cluster” of one — no peers needed) and exposes:

  • REST on port 21001POST /cypher
  • Bolt on port 7687 — for Neo4j drivers and the Neo4j Browser

/data is where the graph, engram history, and node state live — mount a named volume or bind mount so data survives a container restart.

By default the server refuses to start with no password set, so you don’t accidentally run an open database — see Authentication & TLS for how to configure it properly. For a disposable local try-out only (never expose this):

docker run -p 7687:7687 -p 21001:21001 patinadb/patinadb --insecure-disable-auth

Query it once it’s up:

curl -s -u neo4j:change-me -X POST localhost:21001/cypher \
  -H 'content-type: application/json' \
  -d '{"query": "CREATE (n:Person {name: \"Ada\"}) RETURN n"}'

See Quick Start for a walkthrough, and Configuration Reference for every server flag (--id, --addr, --db, --bootstrap, --bolt-addr, and the rest).

Next steps

Quick Start

A five-minute path to a running node and your first query.

Start the server

Start a single self-leading node (quorum of 1 — no peers needed):

docker run -p 21001:21001 -p 7687:7687 -v "$PWD/serverdata:/data" \
  patinadb/patinadb --auth-user neo4j --auth-password secret

(Or run the patinadb-raft binary directly the same way if you have it installed on the host: patinadb-raft --id 1 --addr 127.0.0.1:21001 --db ./serverdata --bootstrap --auth-user neo4j --auth-password secret.)

This exposes:

  • REST on 127.0.0.1:21001POST /cypher
  • Bolt on 127.0.0.1:7687 — for Neo4j drivers and the Browser

Query over REST:

curl -s -u neo4j:secret -X POST 127.0.0.1:21001/cypher \
  -H 'content-type: application/json' \
  -d '{"query": "CREATE (n:Person {name: \"Ada\"}) RETURN n"}'

Connect the Neo4j Browser (browser.neo4j.io) to bolt://localhost:7687 with user neo4j / password secret, and run Cypher interactively — including graph visualisation of returned nodes and paths.

Connect an official driver (Python shown):

from neo4j import GraphDatabase
drv = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "secret"))
with drv.session() as s:
    for rec in s.run("MATCH (n:Person) RETURN n.name AS name"):
        print(rec["name"])

From here:

Cookbook: From Zero to Graph Analytics

This chapter is a single end-to-end walk-through: load → enforce → query → analyze → export → integrate, ending with the versioning and provenance features that make patinaDB distinct. Every command here runs against a live patinaDB server (Installation, Quick Start) over its REST endpoint (curl) or Bolt endpoint (a Neo4j driver / cypher-shell) — pick whichever fits your workflow; both run the identical Cypher.

We’ll build a tiny social graph: Person nodes connected by FOLLOWS relationships. REST examples assume a node reachable at localhost:21001 with Basic auth neo4j:secret (see Quick Start).

1. Load data

For a one-off graph, just CREATE nodes with a query:

curl -s -u neo4j:secret -X POST localhost:21001/cypher \
  -H 'content-type: application/json' \
  -d '{"query": "CREATE (u1:Person {name:\"Ada\", age:36, city:\"London\"})"}'

For a real dataset, use LOAD CSV — a streaming CSV row source in the reading pipeline, just like UNWIND but from a file. Point it at a file the server can read (see file-I/O sandboxing below):

LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CREATE (:Person {
  name: row.name, age: toInteger(row.age), city: row.city
})
# people.csv
name,age,city
Ada,36,London
Grace,40,New York
Linus,29,Helsinki
Margaret,33,Boston
LOAD CSV WITH HEADERS FROM 'file:///data/follows.csv' AS row
MATCH (a:Person {name: row.from}), (b:Person {name: row.to})
CREATE (a)-[:FOLLOWS {since: toInteger(row.since)}]->(b)
  • WITH HEADERS makes each row a map keyed by header (row.name); without it each row is a list of string cells (row[0]).
  • Cells are strings — coerce with toInteger / toFloat / toBoolean. A coercion may sit directly in a CREATE/MERGE pattern property, as above.
  • Only file:// and bare/relative paths are read; http(s):// is rejected.

Loading online, in batches — a lot of rows at once

A bare LOAD CSV … CREATE buffers all resolved writes into one transaction (one Raft entry) before committing — fine for a small file, but an O(rows) memory cliff for a large one. Wrap the write in CALL { … } IN TRANSACTIONS OF n ROWS to chunk it into many small, independently-committed transactions instead:

LOAD CSV WITH HEADERS FROM 'file:///data/accounts.csv' AS row
CALL {
  WITH row
  CREATE (:Account {id: toInteger(row.id), name: row.name})
} IN TRANSACTIONS OF 1000 ROWS

Each batch of 1000 rows commits as its own engram (memory stays O(batch), not O(file)) and, on a cluster, as its own Raft entry. ON ERROR {CONTINUE|BREAK|FAIL} controls what happens to a failing batch. See Bulk Loading & Import.

Loading a lot of rows — bulk upsert

For loading (or reconciling) a large dataset from a file already sitting on the server’s disk, POST /mgmt/upsert (admin-only) resolves and applies a whole columnar file as one match-or-create changeset, index-accelerated and chunkable — see Bulk Upsert.

2. Enforce schema

Add constraints so bad data is rejected at write time. patinaDB supports IS UNIQUE, IS NOT NULL, IS NODE KEY, and IS :: <TYPE> on nodes, plus the same set on relationships — see Constraints for the full list:

CREATE CONSTRAINT person_name FOR (p:Person) REQUIRE p.name IS UNIQUE
status: 'Constraint created on :Person(name) IS UNIQUE'

Creating a constraint over already-populated data fails loudly if the data already violates it. Once in place, a violating write is rejected:

CREATE (p:Person {name: 'Ada'})
error: Unique constraint violation on :Person(name): value already exists

SHOW CONSTRAINTS lists them. Constraint DDL replicates to every node and rides Raft snapshots. See the Cypher chapter for the full DDL grammar.

3. Query

Ordinary Cypher — MATCH, WHERE, ORDER BY, aggregation. List people by age:

MATCH (p:Person) RETURN p.name AS name, p.age AS age ORDER BY p.age DESC

Who has the most followers? A traversal plus a count(*) group-by:

MATCH (:Person)-[:FOLLOWS]->(t:Person)
RETURN t.name AS name, count(*) AS followers
ORDER BY followers DESC, name

Over REST, the response’s scalars block is the clean column view:

"scalars": {
  "name": ["Ada", "Linus", "Grace"],
  "followers": [2, 2, 1]
}

patinaDB passes ~97% of the openCypher TCK; see Cypher Support for the exact coverage and the Limitations chapter for the gaps.

4. Analyze

Built-in, read-only graph algorithms run over an in-memory snapshot of the current graph and yield real nodes plus a per-node result. Rank the influential accounts with PageRank:

CALL patinadb.algo.pageRank('Person','FOLLOWS') YIELD node, score
RETURN node.name AS name, round(score*1000)/1000 AS score
ORDER BY score DESC
"scalars": {
  "name":  ["Ada", "Linus", "Grace", "Margaret"],
  "score": [0.387, 0.374, 0.202, 0.038]
}

patinadb.algo.wcc (weakly-connected components), patinadb.algo.degree (degree centrality), patinadb.algo.betweenness, patinadb.algo.closeness, patinadb.algo.triangleCount, and patinadb.algo.labelPropagation round out the set (seven algorithms in all), with gds.*.stream aliases for tooling. See Procedures.

To understand the shape of your data before writing queries, inspect the planner’s statistics catalog — per-property count, distinct values (NDV), and min/max:

CALL patinadb.stats('Person') YIELD label, property, count, ndv, min, max
RETURN property, count, ndv, min, max ORDER BY ndv
"scalars": {
  "property": ["age", "city", "name", null],
  "count":    [4, 4, 4, 4],
  "ndv":      [4, 4, 4, null],
  "min":      [29, "Boston", "Ada", null],
  "max":      [40, "New York", "Margaret", null]
}

The same catalog feeds cost-based entry-point selection and join ordering — it only ever changes which plan runs, never the result. See Procedures → Statistics catalog.

5. Export

Read the whole graph back out, read-only, into import-compatible files — one node file per label, one relationship file per type. GET /mgmt/export streams a tar archive:

curl -s -u neo4j:secret 'localhost:21001/mgmt/export?db=default&format=csv' \
  -o dump.tar
tar xf dump.tar
$ ls
nodes_Person.csv  rels_FOLLOWS.csv  _schema.json

$ cat nodes_Person.csv
:ID(uuid),age:int,city,name,:LABEL
1ac09593-6a05-5203-b12a-91294189c587,40,New York,Grace,Person
...

?format=parquet / ?format=arrow write the columnar equivalents. Selective exports are also available from within a query: CALL patinadb.export.csv(label, path) or CALL patinadb.export.query(query, file) write a file on the server — see Procedures → CSV export and the file-I/O sandbox note below.

Server file-I/O sandbox

LOAD CSV FROM 'file://…' and the export procedures both touch the server’s own filesystem — they read/write a file on the machine running the query, not on your client. Both are deny-by-default: an operator must explicitly whitelist directories with --allow-csv-dir/--allow-export-dir, and any query doing file I/O requires the global Admin role. See Authentication & TLS → Cypher-driven file I/O.

6. Integrate

  • The server speaks Bolt, so the official Neo4j drivers and the Neo4j Browser point straight at patinaDB.
  • Change Streams (CDC) (GET /changes) let an external system react to graph changes as they happen — cache invalidation, search-index sync, ETL.
  • The full REST API and management endpoints (/mgmt/*) cover backup/restore, cluster administration, and metrics.

7. Time-travel & provenance — the differentiators

Every write is recorded as an engram — a git-like commit. Build up some history:

CREATE (a:Person {name:'Ada', team:'core'})
CREATE (g:Person {name:'Grace', team:'core'})
MATCH (a:Person {name:'Ada'}) SET a.team = 'infra'

List the history (oldest first) with CALL patinadb.engrams(), then view a git-style diff of any single change:

CALL patinadb.diff('<set-engram-id>')
engram ae2eae1a-3fcc-5c26-aad9-b11c4949b5c3
parent:    54f17ad5-bc1d-5686-867a-9891bc906d92
timestamp: 1783448468

~ (7530d903:Person).team: 'core' -> 'infra'

Query the graph as it was at any past engram — no audit table required. Prefix the read with USE <db> AS OF '<engram-id>' (or pass an at field over REST):

USE default AS OF '<prev-engram-id>'
MATCH (a:Person {name:'Ada'}) RETURN a.team AS team
-- team: 'core'   (it is 'infra' at HEAD)

See Time Travel, Diffs, and Engrams.

Finally, Anamnesis auto-projects that change stream into a queryable W3C-PROV graph in a companion <db>__anamnesis database — who, what, when, and optional source / confidence / derived_from — enriched straight from Neo4j transaction metadata. It is opt-in per database (CALL patinadb.anamnesis.enable()). Where other databases leave you to assemble provenance from triggers or CDC, here it ships in the engine.

Where to go next

Data Model

patinaDB stores a labelled property graph.

Vertices (nodes)

A vertex has:

  • A stable UUID identity (assigned on creation, baked into the engram log so replays are deterministic).
  • A primary label plus zero or more secondary labels (multi-label nodes are supported; labels(n) returns all of them).
  • A set of properties — string keys mapped to typed values.
CREATE (a:Person:Employee {name: 'Ada', born: 1815})

Edges (relationships)

An edge is directed, has exactly one type (a label), connects two vertices, and may carry its own properties:

CREATE (a)-[:KNEW {since: 1833}]->(b)

Edges can be traversed in either direction, and patterns may be left-to-right, right-to-left, or undirected (-[:KNEW]-), in which case both directions are searched.

Property value types

The AttributeValue type covers:

TypeExample literalNotes
String'hello'Order-preserving in the value index.
Integer4264-bit signed.
Float3.14, 1.0e9, .564-bit IEEE-754.
Booleantrue, falseFirst-class (AttributeValue::Bool).
NullnullDrives three-valued logic (see below).
List[1, 2, 3]Heterogeneous; indexable, sliceable.
Map{a: 1, b: 'x'}Nested.
Temporaldate('2026-06-28'), datetime(...)Date / Time / LocalTime / DateTime / LocalDateTime / Duration.
Pointpoint({x: 1, y: 2})Cartesian/WGS-84 (2-D or 3-D); order-preserving space-filling-curve index. See Spatial / Geo.
Polygonpolygon([p1, p2, p3])Ring(s) of points, holes supported; not spatially indexed (full-scan only). See Spatial / Geo.
Pathresult of a path patternSequence of nodes and edges.

Three-valued (Kleene) logic

Comparisons and boolean operators follow SQL-style three-valued logic. Any comparison involving null yields Unknown, not true or false:

RETURN 1 = null        // null (Unknown)
RETURN null AND false  // false  (false dominates)
RETURN null OR true    // true   (true dominates)

IS NULL / IS NOT NULL remain strictly two-valued, by specification. In a WHERE filter, Unknown collapses to “not matched”.

Storage internals (informational)

Properties are kept in a label-scoped, order-preserving value index, so a scan over :Person.born never returns :Robot nodes with the same value, and sorted pagination over a property is O(limit) rather than a full scan. Compound indexes over several fields accelerate equality-prefix + sort queries. You normally don’t manage these directly — they are maintained automatically on writes. To declare your own indexes and enforce data integrity, see Constraints and Query Planning; Limitations lists the current DDL gaps.

Cypher Support

patinaDB implements a large, Neo4j-compatible subset of Cypher. The engine is validated against the openCypher Technology Compatibility Kit (TCK): the latest run passes ~3755 / 3868 scenarios (~97.1%). The TCK is treated as a prioritized gap report, not a strict regression gate — so treat this chapter as the authoritative statement of what works.

Reading

  • MATCH with node/edge patterns, optional labels, property maps, and all three directions (->, <-, - undirected).
  • OPTIONAL MATCH.
  • Variable-length paths-[:T*1..3]->, [*0..n] (length-0 self-row), shortestPath.
  • WHERE — full boolean expressions: AND / OR / NOT / XOR, comparisons, IN, IS NULL, STARTS WITH / ENDS WITH / CONTAINS, regex =~, nested parenthesisation.
  • RETURN with DISTINCT, aliases (AS), expressions, ordering.
  • WITH chained query stages (projection, filtering, aggregation hand-off, ordering before projection).
  • UNWIND, UNION / UNION ALL.
  • ORDER BY / SKIP / LIMIT — single-key ordering uses an index fast-path; multi-key falls back to a post-sort.

Loading CSV

LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
WITH toInteger(row.id) AS id, row.name AS name
CREATE (:Person {id: id, name: name})

LOAD CSV [WITH HEADERS] FROM '<url>' AS <var> [FIELDTERMINATOR '<c>'] streams rows from a CSV file into the query as a row source — like UNWIND, but from a file. It is read row by row (the file is never fully buffered).

  • WITH HEADERS → each row is a map keyed by the header, so row.columnName works. Without it, each row is a list of string cells: row[0], row[1], ….
  • Cells are strings (openCypher semantics). Convert explicitly with toInteger / toFloat / toBoolean. A coercion may sit directly inside a CREATE/MERGE pattern property — CREATE (… {p: toInteger(row.x)}) — as well as in a WITH; only a row[i] list-index expression still needs a WITH stage first.
  • FIELDTERMINATOR '<c>' overrides the delimiter (default ,).
  • Sources: file:///absolute/path, file://host/absolute/path (the host is ignored), and bare/relative filesystem paths. Quoted fields and embedded delimiters/newlines are handled. http(s):// URLs are not yet supported.

Large loads (memory): a plain LOAD CSV … CREATE buffers all its writes into one transaction (on the server, one Raft entry), so a very large load has an O(rows) memory cliff. Wrap it in CALL { … } IN TRANSACTIONS (below) to chunk it into many small commits — the online answer. For a large columnar file already on the server, POST /mgmt/upsert is faster still — see Bulk Upsert. A LOAD CSV … RETURN row read query streams end-to-end.

Server-side file reads (security): on the server, LOAD CSV FROM 'file://…' reads a server-side file, so it is deny-by-default: it reads only directories whitelisted with --allow-csv-dir, and any file-I/O query is raised to require the global Admin role. See Bulk Loading → Server file-I/O sandbox and Authentication & TLS.

Batched writes: CALL { … } IN TRANSACTIONS

LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CALL {
  WITH row
  CREATE (:Person {id: toInteger(row.id), name: row.name})
} IN TRANSACTIONS OF 1000 ROWS

CALL { <write-subquery> } IN TRANSACTIONS [OF <n> ROW[S]] [ON ERROR {CONTINUE|BREAK|FAIL}] chunks a large write into many small, independently committed transactions instead of one giant one. The outer stream (typically LOAD CSV … AS row or UNWIND $rows AS r) feeds rows to the inner subquery, which runs and commits every n rows as a separate transaction (default n = 1000). This bounds peak memory to one chunk — the fix for the LOAD CSV … CREATE RAM cliff — and, on a cluster, replicates as one small entry per chunk instead of one unbounded one.

  • Each chunk is its own commit ⇒ its own engram (so batched ingest is versioned and time-travellable, unlike the offline importer), and partial progress is durable: a crash between chunks recovers to a chunk boundary (all-or-nothing per chunk). A later chunk sees the writes committed by earlier chunks.
  • OF <n> ROW[S] sets the chunk size (omit for the 1000 default).
  • ON ERRORFAIL (default) aborts the whole statement on a failing chunk; CONTINUE skips the failing chunk and commits the rest; BREAK stops after the failing chunk, keeping the chunks already committed. A skipped/failed chunk changes nothing (it is resolved against a throwaway copy of the graph, so a partial chunk is never left behind).
  • The CALL { … } IN TRANSACTIONS must be the final clause of the query.

Writing

  • CREATE, MERGE (match-or-create), SET (properties, map merge, and SET n:Label label mutation), REMOVE, DELETE / DETACH DELETE.
  • FOREACH.
  • Writes inside WITH stages.

Expressions & functions

  • Arithmetic, string, list, and map operators; list/map indexing and slicing.
  • Aggregationcount (incl. count(*)), sum, avg, min, max, collect, with GROUP BY semantics (mix of aggregated and grouping keys in RETURN), DISTINCT inside aggregates, and aggregate hoisting through nested function calls and arithmetic.
  • CASE (simple and generic).
  • List comprehensions and pattern comprehensions.
  • Quantifiersany / all / none / single, including aggregate and count(*) sources.
  • Scalar functionstoInteger, toString, size, labels, type, nodes, relationships, range, and many more.
  • Temporal functionsdate, time, localtime, datetime, localdatetime, duration, plus namespaced calls (date.truncate, duration.between, datetime.fromepoch) with ISO-8601 week numbering.

Value semantics

Numeric equivalence

patinaDB treats an integer and a mathematically-equal float as the same value for equivalence purposes, matching Neo4j. The fold is exact: a float counts as its integer twin only when it is finite, has a zero fractional part, and round-trips bit-for-bit ((f as i64) as f64 == f) — so a large integer that is not exactly float-representable never falsely merges, and two genuinely different integers never collide.

Because of this, DISTINCT, an implicit GROUP BY key, and a distinct UNION all merge numeric twins:

UNWIND [1, 1.0] AS x RETURN DISTINCT x
-- 1 row (previously 2)

UNWIND [1, 1.0, 2] AS x RETURN x, count(*)
-- 1 → 2, 2 → 1     (1 and 1.0 fall into one group)

The surviving value keeps its original type — first occurrence wins (as in Neo4j), so the result shows whichever of 1 / 1.0 appeared first, not a normalized form. UNION ALL is unaffected (it never de-duplicates). The same equivalence drives numeric-twin-aware UNIQUE constraints.

Cross-type ORDER BY

When a single ORDER BY column holds values of different types, patinaDB follows Neo4j’s documented precedence, lowest to highest:

Map < Node < Relationship < List < Path < Temporal
    < String < Boolean < Number < Point < Polygon < NaN < Null

Every temporal type (Date, Time, LocalTime, DateTime, LocalDateTime, Duration) now sorts between Path and String — i.e. before strings and numbers (previously they sorted after numbers). min() and max() follow the same order. This applies only to a mixed-type column; a column of a single type is ordered normally and is unaffected. (Point and Polygon are patinaDB spatial extensions beyond Neo4j’s list, shown here for completeness.)

Arithmetic errors

Integer division and modulo by zero raise Neo.ClientError.Statement.ArithmeticError. Integer +, -, *, and sum() over integers raise the same error on i64 overflow, rather than silently saturating or wrapping. A mixed integer/float expression is promoted to float before the operation (so it never overflows there — Neo4j parity), and float arithmetic is unchanged: it still yields ±Inf / NaN, not an error.

Procedures

CALL invokes built-in procedures (history, diff, full-text/vector search, graph algorithms, statistics, export, cache stats). See Procedures. User-defined scripting-language procedures are not supported, but user-defined read-only WASM procedures are — see WASM Procedures.

DDL

  • CREATE INDEX / DROP INDEX / SHOW INDEXES — single-property and compound (multi-field) B-tree indexes.

  • CREATE CONSTRAINT / DROP CONSTRAINT / SHOW CONSTRAINTS — all four Neo4j constraint kinds (uniqueness, existence, node-key/relationship-key, property-type), on both nodes and relationships. See Constraints.

  • CREATE FUNCTION … LANGUAGE wasm / DROP FUNCTION / SHOW FUNCTIONS — user-defined, sandboxed, read-only WASM procedures. See WASM Procedures.

  • CREATE POINT INDEX / DROP POINT INDEX / SHOW POINT INDEXES — accelerates spatial radius/bbox/kNN queries. See Spatial / Geo.

  • CREATE EDGE SORTED INDEX (patinaDB extension) — pre-orders, per anchor vertex, that anchor’s targets over one relationship type by a target property, so a traversal-fan-out + ORDER BY target.prop [DESC] LIMIT k is served by a seek + take (no fan-out, no post-sort — bench Q5–Q8). Two orientations:

    CREATE EDGE SORTED INDEX [name] FOR ()-[:REPORTED]->(m:Ticket) ON m.created_at
    CREATE EDGE SORTED INDEX [name] FOR (m:Ticket)<-[:HAS_LABEL]-()  ON m.created_at
    SHOW EDGE SORTED INDEXES
    

    Creating the index over a populated graph backfills existing edges; it is kept live on writes. EXPLAIN names limit.edge_sorted_topk once a covering index serves the query. On a server it replicates to every node (deterministic re-run). The name is optional and cosmetic (the def is identified by its shape). Not yet carried in Raft snapshots — see Limitations.

  • CREATE FULLTEXT INDEX / DROP INDEX / SHOW FULLTEXT INDEXES — see Full-Text Search.

  • CREATE DATABASE / DROP DATABASE / SHOW DATABASES (server only) — see Multi-Database.

  • EXPLAIN and PROFILE render the physical operator tree.

Query introspection

EXPLAIN MATCH (p:Person)-[:KNEW]->(q) WHERE p.born < 1850 RETURN q
PROFILE MATCH (p:Person {name: 'Ada'}) RETURN p

EXPLAIN shows the chosen plan (scan strategy: compound index, property-value index, label scan, or all-vertices) without running it; PROFILE runs and annotates it.

What is not supported

A precise list lives in Limitations. The headline gaps: no general scripting-language user-defined procedures (only sandboxed read-only WASM procedures), no triggers, and the ~5% of TCK scenarios that remain — mostly exotic temporal/list edge cases. (Index DDL and all four constraint kinds, on nodes and relationships, are supported; see Constraints and Limitations for the exact scope.) Side-effect counters (+nodes, -relationships in query summaries) are not tracked.

Bulk Loading & Import

patinaDB offers three ways to get a lot of data into a running server, all of them versioned (recorded in the engram log) and, on a cluster, replicated:

PathWhereEngramsMemoryBest for
POST /mgmt/upsert family (RFC-0020)REST, admin+leader-onlyOne per changeset (or per chunk)O(changeset) or O(chunk)A large columnar file, or a coupled node+edge subgraph, already on the server’s disk. Index-accelerated match-or-create.
LOAD CSV … CALL { … } IN TRANSACTIONSCypherOne per chunkO(chunk)Live ingest of a CSV stream into a running (possibly clustered) database.
LOAD CSV … CREATE (un-chunked)CypherOneO(rows) cliffSmall loads, or LOAD CSV … RETURN reads.

Bulk upsert (large files, match-or-create)

POST /mgmt/upsert, /mgmt/upsert-edges, and /mgmt/upsert-graph resolve and apply a whole columnar file (CSV / Parquet / Arrow) — or a coupled JSON node+edge subgraph — as one changeset, per-row match-or-create against a match key, index-accelerated. A chunk_rows field splits a very large load into many bounded, independently-committed engrams so peak memory (and Raft entry size) stays O(chunk) instead of O(rows).

curl -s -u neo4j:secret -X POST localhost:21001/mgmt/upsert \
  -H 'content-type: application/json' \
  -d '{"db":"default","file":"/srv/import/people.csv","label":"Person","match":["email"]}'

The file path is confined to the server’s filesystem sandbox (see below); this is the fastest, largest-scale online path — see Bulk Upsert for the full request shape, merge/conflict policies, and chunking guidance.

Online: LOAD CSV

LOAD CSV streams rows from a CSV file into a running query as a row source — like UNWIND, but from a file. It goes through the normal write path, so each load is versioned (an engram) and, on a cluster, replicated.

LOAD CSV WITH HEADERS FROM 'file:///data/people.csv' AS row
CREATE (:Person {id: toInteger(row.id), name: row.name})
  • WITH HEADERS makes each row a map keyed by header (row.name); without it each row is a list of string cells (row[0]).
  • Cells are strings — coerce with toInteger / toFloat / toBoolean. A coercion may sit directly in a CREATE/MERGE pattern property (as above); a row[i] list-index expression still needs a WITH stage first.
  • FIELDTERMINATOR '<c>' overrides the , delimiter. http(s):// URLs are rejected; only file:// and bare/relative paths are read.

The full clause reference is in Cypher Support → Loading CSV.

The un-chunked memory cliff

A bare LOAD CSV … CREATE streams the source row by row, but buffers all resolved write operations into one transaction (on the server, one Raft entry) before committing — an O(rows) memory cliff for a large load. A LOAD CSV … RETURN row read query streams end to end and has no such cliff.

Online + chunked: CALL { … } IN TRANSACTIONS

Wrapping the write subquery in CALL { … } IN TRANSACTIONS chunks the load into many small, independently-committed transactions — the online answer to the RAM cliff. Peak memory drops to one chunk, and on a cluster each chunk replicates as one small Raft entry instead of one unbounded one.

LOAD CSV WITH HEADERS FROM 'file:///data/more_people.csv' AS row
CALL {
  WITH row
  CREATE (:Reader {id: row.id, name: row.name, age: toInteger(row.age)})
} IN TRANSACTIONS OF 2 ROWS

Loading a 5-row file OF 2 ROWS produces ⌈5/2⌉ = 3 chunks — and three separate engrams, one per committed chunk:

MATCH (r:Reader) RETURN count(r) AS n
-- n: 5
CALL patinadb.engrams() YIELD id RETURN count(id) AS n
-- n: 3 (for this one load, on a fresh database)
  • Each chunk is its own commit ⇒ its own engram, so batched ingest is versioned and time-travellable. A later chunk sees the writes committed by earlier chunks (read-your-writes).
  • OF <n> ROW[S] sets the chunk size (default 1000).
  • ON ERRORFAIL (default) aborts the whole statement on a failing chunk; CONTINUE skips it and commits the rest; BREAK stops after it, keeping earlier chunks. A skipped/failed chunk changes nothing — it is resolved against a throwaway copy of the graph, so a partial chunk is never left behind.
  • Crash-atomic per chunk: a crash between chunks recovers to a chunk boundary. The graph and the engram log always agree.
  • The CALL { … } IN TRANSACTIONS must be the final clause of the query, and it cannot run inside an explicit Bolt BEGIN … COMMIT transaction.

On the server the driver proposes one client_write per chunk, so a 250-row load OF 100 ROWS advances the applied index by 3, not 1 — bounded Raft entries, read-your-writes preserved across chunks on the leader.

Server file-I/O sandbox

Reading a file:// URL, or writing with the export procedures, touches the server’s filesystem. On the server both are deny-by-default and gated by two layers:

  1. Directory sandbox--allow-csv-dir <dir> (reads) and --allow-export-dir <dir> (writes) whitelist directories; unset means every file access is refused. Paths are canonicalized, so .. traversal and symlink escapes are rejected.
  2. Authorize by effect — any query that does file I/O is raised to require the global Admin role, because file access is a host-level capability, not a graph-data one. A per-database Writer cannot read or write host files.

/mgmt/upsert and its siblings confine their file path through the same --allow-csv-dir sandbox and are admin-only by virtue of living under /mgmt/. See Authentication & TLS → Cypher-driven file I/O.

Which path should I use?

  • A large columnar file (or subgraph document) already on the serverPOST /mgmt/upsert/upsert-edges/upsert-graph (fastest, index-accelerated match-or-create, chunkable).
  • Live ingest into a running / clustered database from a streamed CSVLOAD CSV … CALL { … } IN TRANSACTIONS OF n ROWS (versioned, replicated, bounded memory).
  • A small load, or you need the CSV as a read source → plain LOAD CSV … CREATE / LOAD CSV … RETURN row.

Bulk Upsert (match-or-create)

Where Bulk Loading is about getting a lot of data in quickly, upsert is about reconciling data: for each input record, find an existing node (or edge) by a match key and UPDATE it, or CREATE it if it isn’t there. It is a bulk, index-accelerated MERGE — served online, over REST, by the leader.

By default, every upsert runs the versioned path — through the engram log, bracketed by a pre-load marker — so the whole load, however large, reverts as one unit (see Time Travel). A non-unique match key (or any other ambiguity) fails loud and leaves the graph untouched.

There are three flavours, all admin- and leader-only REST endpoints:

EndpointUpsertsMatch key
POST /mgmt/upsertnodes from a columnar file (CSV / Parquet / Arrow) on the server’s diskmatch: [attr,…]
POST /mgmt/upsert-edgesedges between existing nodes from a columnar fileendpoint keys on each side
POST /mgmt/upsert-grapha whole node+edge subgraph from one JSON document (posted as the request body)per-label identity

All three accept a chunk_rows field: each chunk is one replicated Raft entry, so a very large load doesn’t ship as one giant entry (see Chunking a very large load). For /mgmt/upsert-graph, chunk_rows counts resolved graph operations per entry rather than input rows, since the whole document is one coupled payload.

A non-unique endpoint/match key that resolves to more than one node always fails loud — there is no policy that can silently pick one.

Node upsert — POST /mgmt/upsert

Match-or-create nodes on a per-batch match key. The file path is confined to the server’s filesystem via the same --allow-csv-dir sandbox LOAD CSV uses (see Bulk Loading → Server file-I/O sandbox).

curl -s -u neo4j:secret -X POST localhost:21001/mgmt/upsert \
  -H 'content-type: application/json' \
  -d '{
    "db": "default",
    "file": "/srv/import/people.csv",
    "label": "Person",
    "match": ["email"],
    "merge": "add",
    "on_conflict": "error",
    "strategy": "seek"
  }'
{"created": 2, "updated": 1, "skipped": 0, "conflicts": 0,
 "pre_load": "6f2f…", "post_load": "a1e3…",
 "pre_load_tag": "_upsert_pre_1699…",
 "revert_hint": "restore_to('6f2f…')"}

Per row, an existing :Person is found by exact equality on the match key (one attribute, or a composite ["first","last","dob"]). Found → UPDATE per merge; not found → CREATE. Re-running the same file is idempotent (it updates in place, never duplicates).

  • merge: "add" (default) — add/overwrite the row’s attributes, keep the rest.
  • merge: "replace" — the node keeps ONLY the row’s attributes (drops others).
  • merge: "add-missing" — set only attributes the node does not already have.
  • on_conflict: "error" (default) — a match key that hits more than one node fails loud. "update-all" updates every match; "skip" leaves them, counts them.
  • strategy: "seek" (default) or "sort-merge" — two byte-identical lookup strategies; sort-merge reads the index in one sequential pass and is faster when the input is a large fraction of the label.
  • chunk_rows — for a load too large to hold in RAM as one changeset (see below).

Concurrency safety. The resolve+propose runs through the same optimistic conflict-check pipeline REST autocommit writes use, so two concurrent upserts of the same value don’t both create a duplicate — the loser’s retry re-resolves against fresh HEAD and MATCHes the winner’s just-created node. This detection needs the match key to be a UNIQUE or NODE KEY constraint (see Constraints); an unconstrained match key is not conflict-detected online.

Chunking a very large load

By default an upsert is applied as one changeset (one engram), so the whole resolved batch is held in RAM. That is fine up to millions of rows, but a truly huge load (tens of millions of nodes/edges) can exhaust memory.

Pass chunk_rows to process the load in chunks of N rows, each applied as its own changeset — so peak memory stays bounded by the chunk size, not the total row count, and an arbitrarily large file loads:

curl -s -u neo4j:secret -X POST localhost:21001/mgmt/upsert \
  -H 'content-type: application/json' \
  -d '{"db":"default","file":"/srv/import/huge.parquet","label":"Person","match":["email"],"chunk_rows":100000}'

The final graph is identical to an un-chunked load for any chunk size (a row whose key an earlier chunk already created is matched, not duplicated), and the whole load is still revertable as one unit via the returned pre-load tag.

Two things to know about the trade-off:

  • Chunking is somewhat slower (each chunk pays a small per-changeset overhead) — a larger chunk_rows means less overhead but more memory.
  • A chunked load is not all-or-nothing: if a chunk fails partway through (e.g. a non-unique match key), the earlier chunks stay applied. Recover the pre-load state with the returned revert hint. (Without chunk_rows, a failed upsert leaves the graph completely untouched — use the default when the whole load must be atomic and fits in memory.)

/mgmt/upsert-edges takes the same chunk_rows field with the same semantics. /mgmt/upsert-graph applies all the subgraph’s nodes in chunks first (so an edge always finds its endpoints), then the edges in chunks. Note the JSON document itself is still held in memory as one coupled payload — chunking bounds the per-changeset memory, not the size of the document you can post in one call.

Edge upsert — POST /mgmt/upsert-edges

Match-or-create relationships between existing nodes. Each row’s start and end endpoints are resolved by the same match machinery as node upsert, then the edge is matched on its canonical (start, end, type) identity.

curl -s -u neo4j:secret -X POST localhost:21001/mgmt/upsert-edges \
  -H 'content-type: application/json' \
  -d '{
    "db": "default",
    "file": "/srv/import/works_at.csv",
    "start_label": "Person", "start_match": ["email"],
    "end_label": "Company", "end_match": ["name"],
    "rel": "WORKS_AT"
  }'

Every column named in start_match/end_match is consumed as an endpoint match value and is never an edge property; every other column is an edge property. Because patinaDB edges are id-less (at most one edge per (start,end,type)), there is no conflict policy for the edge itself — only on_missing governs a row whose endpoint match finds no node:

  • "error" (default) — fail the whole load loud, touching nothing.
  • "skip" — leave the row uncreated, count it, keep going.
  • "create"CREATE the missing endpoint node too, instead of failing or skipping. The created node’s properties are set to exactly its match-key attribute(s) — nothing else. Two rows in the same batch that reference the SAME missing endpoint (same label + same match value) resolve to the SAME newly-created node, so a file with many edges into one new node never creates duplicates.
{"created": 2, "updated": 0, "skipped": 0, "missing_endpoints": 1,
 "pre_load": "6f2f…", "post_load": "a1e3…",
 "pre_load_tag": "_upsert_edges_pre_1699…"}

Subgraph upsert — POST /mgmt/upsert-graph

The most powerful form: match-or-create a whole node+edge subgraph described in ONE coupled JSON document, posted as the request body, applied as one revertable engram.

Edges reference nodes by a fake id — a payload-local handle (unique within the document) used ONLY to wire edges[].from/.to to nodes[].id. A fake id is never stored; the server replaces each with the real DB UUID the node resolved to. This is what makes a homogeneous self-relationship (e.g. Person KNOWS Person) trivial — something a value-only edge match can’t express.

{
  "identity": { "Person": ["email"], "Company": ["name"] },
  "merge": "add",
  "nodes": [
    { "id": "p1", "label": "Person",  "props": { "email": "a@x", "name": "Alice", "age": 30 } },
    { "id": "c1", "label": "Company", "props": { "name": "Acme", "industry": "tech" } }
  ],
  "edges": [
    { "from": "p1", "to": "c1", "type": "WORKS_AT", "props": { "since": 2020 } }
  ]
}
curl -s -u neo4j:secret -X POST localhost:21001/mgmt/upsert-graph \
  -H 'content-type: application/json' --data-binary @graph.json
{"nodes_created": 2, "nodes_updated": 0, "edges_created": 1, "edges_updated": 0,
 "pre_load": "6f2f…", "post_load": "a1e3…",
 "pre_load_tag": "_upsert_graph_pre_1699…"}

Any number of node and relationship types

A subgraph document can mix as many node labels and relationship types as you like — each label is matched-or-created by its own identity, and the edges wire fake ids together across type boundaries. One document, one revertable engram:

{
  "nodes": [
    { "id": "p1", "label": "Person",  "props": { "email": "alice@x", "name": "Alice" } },
    { "id": "c1", "label": "Company", "props": { "name": "Acme" } },
    { "id": "s1", "label": "Skill",   "props": { "name": "Rust" } },
    { "id": "s2", "label": "Skill",   "props": { "name": "Graphs" } }
  ],
  "edges": [
    { "from": "p1", "to": "c1", "type": "WORKS_AT",  "props": { "since": 2020 } },
    { "from": "p1", "to": "s1", "type": "HAS_SKILL", "props": { "level": 5 } },
    { "from": "p1", "to": "s2", "type": "HAS_SKILL" }
  ]
}

Post this with an identity block naming Person=email, Company=name, and Skill=name — Alice is now connected to a Company and two Skills in one shot: three node types, two relationship types, resolved and applied together.

Where the match key (identity) comes from

Which properties make a node unique is resolved per label:

  1. a persisted UNIQUE or NODE KEY constraint on the label — then you can omit the identity block entirely; else
  2. the document’s identity block.

If both a constraint and a differing identity block exist, or neither exists, the load fails loud — patinaDB never guesses how to match your nodes.

An edge whose from/to names a fake id that isn’t in the document fails loud (the payload must be self-contained). merge (default "add") applies to node and edge properties, exactly as for node upsert.

The homogeneous case (Person KNOWS Person)

{
  "identity": { "Person": ["email"] },
  "nodes": [
    { "id": "p1", "label": "Person", "props": { "email": "a@x" } },
    { "id": "p2", "label": "Person", "props": { "email": "b@x" } },
    { "id": "p3", "label": "Person", "props": { "email": "c@x" } }
  ],
  "edges": [
    { "from": "p1", "to": "p2", "type": "KNOWS" },
    { "from": "p2", "to": "p3", "type": "KNOWS" }
  ]
}

The KNOWS edges land on exactly a→b and b→c (and NOT a→c) — the fake ids disambiguate which Person is which, even though every node has the same label.

Edge cardinality — 1-to-many vs 1-to-1

By default an edge is 1-to-many: many :WORKS_AT edges can leave one Person. Set an edge’s "identity" field to make it 1-to-1 — useful when a relationship is exclusive (one employer, one current owner, …).

"identity"IdentityMeaning
"both" (default)(from, to, type)1-to-many: a-[:R]->b and a-[:R]->c coexist.
"from"(from, type)1-to-1: at most ONE :R edge per from — a new target replaces the old.
"to"(to, type)1-to-1 incoming: at most one incoming :R edge per to.

1-to-many (default) — targets coexist. Load a-[:WORKS_AT]->Acme, then a-[:WORKS_AT]->Globex, and Alice works at both:

{ "from": "p1", "to": "c1", "type": "WORKS_AT" }

1-to-1 ("from") — the new target replaces the old. With a-[:WORKS_AT]->Acme already present, upserting:

{ "from": "p1", "to": "c2", "type": "WORKS_AT", "identity": "from" }

deletes the a→Acme edge and creates a→Globex — Alice now works at exactly one company. Re-upserting the same target just updates that edge’s properties.

Extra edge-property uniqueness — "match"

Because patinaDB edges are id-less, there is at most one physical edge per (from,to,type). An optional per-edge "match": ["attr",…] names edge properties that are part of the edge’s logical identity and acts as a guard: if a committed edge already occupies the triple but its match-property values differ from what you’re upserting, the load fails loud rather than silently overwriting a semantically-different edge. It composes with any cardinality mode.

Reverting a load

Every upsert is a single revertable changeset. The returned pre_load HEAD (and its replicated tag) is the exact state before the load. On the server:

USE default AS OF TAG '_upsert_pre_1699…'
MATCH (n) RETURN count(n)

Or inspect the whole load as one diff — CALL patinadb.diffRange('<pre_load>', '<post_load>') (see Diffs and Time Travel). The pre-load tag is replicated, so USE <db> AS OF TAG '<tag>' reads the graph as it was before the load on any node.

Constraints

patinaDB supports uniqueness, existence (NOT NULL), node-key, and property-type (IS :: <TYPE>) constraints on nodes, plus existence, property-type, uniqueness, and relationship-key constraints on relationships, with Neo4j-5 (and legacy Neo4j-4) DDL — all four Neo4j constraint kinds, on both nodes and relationships. Constraints are enforced at write time, replicate across a cluster, and are carried in backups and Raft snapshots.

Creating constraints

CREATE CONSTRAINT [name] [IF NOT EXISTS]
FOR (n:Label) REQUIRE n.prop IS UNIQUE

Node kinds:

KindDDLEnforces
UniquenessREQUIRE n.prop IS UNIQUENo two :Label nodes share a non-null value for prop.
ExistenceREQUIRE n.prop IS NOT NULLEvery :Label node has prop set (non-null).
Node keyREQUIRE (n.p1, n.p2) IS NODE KEYThe tuple is present on every :Label node and unique (composite unique + existence).
Property typeREQUIRE n.prop IS :: INTEGERWhen present, prop’s value is of the declared type (does not require presence).

Relationship kinds (FOR ()-[r:TYPE]-(), any direction):

KindDDLEnforces
ExistenceREQUIRE r.prop IS NOT NULLEvery :TYPE relationship has prop set (non-null).
Property typeREQUIRE r.prop IS :: INTEGERWhen present, prop’s value is of the declared type.
UniquenessREQUIRE r.prop IS UNIQUENo two :TYPE relationships share a non-null value for prop.
Relationship keyREQUIRE (r.p1, r.p2) IS [REL|RELATIONSHIP] KEYThe tuple is present on every :TYPE relationship and unique.

Relationship uniqueness/key is index-backed by the existing edge-property value index (O(log N + matches)), not a full scan of the relationship type.

The legacy Neo4j-4 forms are also accepted: CREATE CONSTRAINT … ON (n:Label) ASSERT n.prop IS UNIQUE and the shorthand ON :Label(prop). The property-type predicate also accepts the IS TYPED <TYPE> synonym of IS :: <TYPE>.

Property types

IS :: <TYPE> (and IS TYPED <TYPE>) accepts: BOOLEAN, STRING, INTEGER, FLOAT, POINT, DATE, LOCAL TIME, ZONED TIME, LOCAL DATETIME, ZONED DATETIME, DURATION, and LIST (BOOL/INT are accepted as aliases). A missing or null property is allowed — a type constraint restricts a present value’s type only; combine it with an existence constraint if you also need the property present. Element-type refinement (LIST<INTEGER NOT NULL>) is not yet supported (bare LIST only).

CREATE CONSTRAINT person_email FOR (p:Person) REQUIRE p.email IS UNIQUE
-- status: 'Constraint created on :Person(email) IS UNIQUE'

CREATE CONSTRAINT emp_key FOR (e:Employee) REQUIRE (e.dept, e.num) IS NODE KEY
-- status: 'Constraint created on :Employee(dept, num) IS NODE KEY'

The optional name identifies the constraint for DROP and SHOW. IF NOT EXISTS makes creation idempotent.

Creating over existing data

CREATE CONSTRAINT first validates the current graph. If the existing data already violates the constraint (a duplicate value, or a missing required property), the statement fails and no constraint is registered — fix the data and re-run.

Enforcement semantics

Enforcement runs at write time on CREATE, MERGE, and SET / REMOVE — including a SET / REMOVE inside a FOREACH body, which is enforced identically to a top-level write:

CREATE (:Person {email: 'ada@x.io', name: 'Ada'})

CREATE (:Person {email: 'ada@x.io', name: 'Ada2'})
-- error: Unique constraint violation on :Person(email): value already exists
  • UNIQUE is NULL-exempt. A null (or absent) value is not constrained — many nodes (or relationships) may omit prop. Only two concrete equal values collide. (Use a node-key/relationship-key or a separate existence constraint if you also need the property present.)
  • 1 and 1.0 are the same value. Uniqueness treats an integer and a mathematically-equal float as identical (matching Neo4j), so under UNIQUE(k) a stored Integer 1 collides with a CREATE/SET of 1.0, and vice-versa. This holds for node UNIQUE, relationship UNIQUE, and every column of a composite NODE KEY / REL KEY, on both the single-writer path and concurrent transactions. The fold is exact — a float merges with an integer only when it is finite, whole, and round-trips bit-for-bit — so two genuinely different values never collide, and NULL stays exempt as above. See Cypher → numeric equivalence for the full rule. (Very large exact-integer float values — roughly ≥ 2^59, e.g. 1e18 — are still enforced correctly, but fall back to a scan instead of an index seek, so those checks can be slower.)
  • Existence and node-key/relationship-key reject a missing/null required property — including a CREATE/MERGE that omits it, a SET n.prop = null, or a REMOVE n.prop. This holds for both node existence and relationship existence (checked at edge create, SET r.prop = null, SET r =/+=, and REMOVE r.prop).
  • Property-type rejects a present wrong-typed value on CREATE/MERGE/SET (node or relationship). A missing/null value is allowed.
  • The uniqueness check is an O(log N + matches) seek of the label-scoped value index (node) or the edge-property value index (relationship), so it is cheap even on a large label/type.
  • Node-key uniqueness is served by an automatically registered backing compound index over the key tuple — you do not create it separately. Relationship-key uniqueness is served the same way over the edge-property value index (no separate index to create).

Inspecting and dropping

SHOW CONSTRAINTS
constraints: ['person_email: :Person(email) IS UNIQUE', 'emp_key: :Employee(dept, num) IS NODE KEY', 'person_name: :Person(name) IS NOT NULL']

Relationship constraints look the same, scoped by type instead of label, e.g. REQUIRE (r.a, r.b) IS REL KEY FOR ()-[r:WORKS_AT]-().

DROP CONSTRAINT person_email [IF EXISTS]

Replication and durability

  • Cluster replication is automatic. Constraint DDL replicates through the Raft log and re-runs deterministically on every node (the same IndexDdl path that index DDL uses).
  • Raft snapshots carry the defs. A follower that bootstraps purely from a streamed snapshot (after the DDL log is purged) still enforces every constraint.
  • Portable backups carry the defs too. GET /mgmt/snapshot / POST /mgmt/restore round-trip UNIQUE, existence, and node-key defs (alongside vector indexes, tags, and RBAC users) — restore re-issues them as CREATE CONSTRAINT … IF NOT EXISTS. See the REST API.

Not supported

  • LIST<T> element-type refinement — only bare LIST is accepted.
  • Bare composite UNIQUE without existence — use IS NODE KEY / IS [REL|RELATIONSHIP] KEY for a multi-property key (which also requires presence).

See Limitations for the full schema-feature scope, and the Data Model for how properties and the value index work.

Edge-Sorted Indexes

An edge-sorted index keeps, per anchor vertex, that anchor’s neighbours over one relationship type pre-ordered by a property of the neighbour. It turns the query shape “a node’s neighbours, ordered by a property of the neighbour, top-N, paginated” from a traverse-then-sort into a seek + scan — no fan-out, no post-sort.

This is a patinaDB extension. The grammar reference lives in Cypher Support → DDL; this chapter is the conceptual and worked-example companion.

The problem it solves

One shape shows up nearly everywhere you build on a graph:

  • a user’s most recent tickets,
  • the newest comments under a post,
  • a person’s activity feed / timeline,
  • the top-N items per parent, ordered by a timestamp, score, or price.

In Cypher it reads:

MATCH (u:User {name: 'Ada'})-[:REPORTED]->(t:Ticket)
RETURN t
ORDER BY t.created_at DESC
LIMIT 10

Simple to write, but normally expensive. To return ten rows the engine must:

  1. traverse the whole fan-out — every REPORTED edge Ada has, even if she filed 50 000 tickets,
  2. read the sort property (created_at) off every one of those targets, then
  3. sort all of them just to discard everything below the top ten.

The work is proportional to the anchor’s degree, not to the LIMIT. For a hub vertex — a power user, a popular post, a busy parent — that is the difference between a feed that renders instantly and one that stalls.

What patinaDB does

An edge-sorted index stores a sorted adjacency: for each anchor, its targets over one relationship type are already laid out in target-property order. The query above becomes:

  1. seek to the anchor’s slice of the index,
  2. iterate in created_at order (DESC walks it in reverse),
  3. take k and stop.

That is O(n + k) (skip n, take k) — no fan-out, no materialising the whole neighbour set, no post-sort. A power user with 50 000 tickets costs the same top-ten as one with twelve. The index is maintained synchronously on every write (edge create/delete, and moving a target when its sort property changes), so it is never stale.

The DDL and a worked example

The syntax names the traversal shape and the target property to order by. Both orientations are supported:

-- outbound anchor: (anchor)-[:REL]->(target)
CREATE EDGE SORTED INDEX [name] FOR ()-[:REPORTED]->(m:Ticket) ON m.created_at

-- inbound anchor: (target)<-[:REL]-(anchor)
CREATE EDGE SORTED INDEX [name] FOR (m:Ticket)<-[:HAS_LABEL]-()  ON m.created_at

SHOW EDGE SORTED INDEXES

The name is optional and cosmetic — a def is identified by its shape (direction, relationship type, target label, target property), so two CREATEs of the same shape are idempotent.

The following was run end-to-end against a live server on a tiny User → REPORTED → Ticket graph; the output is real.

Build the graph

CREATE (u:User {name:'Ada'})
CREATE (t1:Ticket {id:1, title:'Login fails',    created_at:'2026-07-01T09:00:00'})
CREATE (t2:Ticket {id:2, title:'Slow dashboard', created_at:'2026-07-03T14:30:00'})
CREATE (t3:Ticket {id:3, title:'Export broken',  created_at:'2026-07-05T08:15:00'})
CREATE (t4:Ticket {id:4, title:'Typo in header', created_at:'2026-07-06T11:45:00'})
CREATE (u)-[:REPORTED]->(t1)
CREATE (u)-[:REPORTED]->(t2)
CREATE (u)-[:REPORTED]->(t3)
CREATE (u)-[:REPORTED]->(t4)

Create the index

$ patinadb feeddb query \
    "CREATE EDGE SORTED INDEX ticket_feed FOR ()-[:REPORTED]->(m:Ticket) ON m.created_at"
status: 'Edge sorted index ticket_feed created FOR ()-[:REPORTED]->(:Ticket) ON created_at'

$ patinadb feeddb query "SHOW EDGE SORTED INDEXES"
edgeSortedIndexes: ['FOR ()-[:REPORTED]->(:Ticket) ON created_at']

Creating the index over a populated graph backfills the existing edges, so you can create it any time.

Page 1 — newest first

$ patinadb feeddb query \
    "MATCH (u:User {name:'Ada'})-[:REPORTED]->(t:Ticket)
     RETURN t.id, t.title, t.created_at
     ORDER BY t.created_at DESC LIMIT 2"
t.created_at: ['2026-07-06T11:45:00', '2026-07-05T08:15:00']
t.id: [4, 3]
t.title: ['Typo in header', 'Export broken']

Confirm the index serves it

EXPLAIN shows the physical plan. When an edge-sorted index covers the query, the Physical: footer names limit.edge_sorted_topk:

$ patinadb feeddb query \
    "EXPLAIN MATCH (u:User {name:'Ada'})-[:REPORTED]->(t:Ticket)
     RETURN t ORDER BY t.created_at DESC LIMIT 2"
plan: 'Limit skip=0 count=2
  Project items=1 distinct=false return_star=false
    Sort keys=1
      MatchScan required=[(u:User) -[entry](t:Ticket) → PropertyIndex[created_at] SORTED SCAN DESC est.5]
Physical:
  limit.edge_sorted_topk
'

If you drop the index (or the query shape does not match), the footer names a different path — always run EXPLAIN to confirm the index is doing the work.

Page 2 — keyset pagination

To page forward, carry a cursor: the sort value of the last row on the previous page (2026-07-05T08:15:00), and ask for rows strictly beyond it. For a DESC feed that is <:

$ patinadb feeddb query \
    "MATCH (u:User {name:'Ada'})-[:REPORTED]->(t:Ticket)
     WHERE t.created_at < '2026-07-05T08:15:00'
     RETURN t.id, t.title, t.created_at
     ORDER BY t.created_at DESC LIMIT 2"
t.created_at: ['2026-07-03T14:30:00', '2026-07-01T09:00:00']
t.id: [2, 1]
t.title: ['Slow dashboard', 'Login fails']

Keyset pagination is stable under concurrent writes (unlike SKIP n, which shifts when rows are inserted) and never re-scans the pages you have already seen.

Honest note on the cursor page. The bare top-N shape (page 1) is served by limit.edge_sorted_topk. Adding the keyset WHERE t.created_at < … currently changes the plan shape, so page 2 falls back to limit.bounded_topk — a bounded top-K heap that is O(k) memory and returns the correct rows, but still reads the fan-out to apply the filter. Pushing the cursor into an edge-sorted-index seek (so deep pages are O(log n + k)) is a planned follow-up. In practice the first page — the one users actually load — is the hot path, and it is fully covered.

When to use it — and when not

Reach for it when you have the “top-N neighbours by a neighbour property” shape over a skewed fan-out (some anchors have far more neighbours than others) and you want the first page to stay fast regardless of degree. Feeds, timelines, “latest N per parent”, and leaderboards-per-group are the sweet spot.

Each index covers exactly one (direction, relationship type, target label, target property) combination. If you sort the same neighbours by two different properties, or traverse two relationship types, you create one index per shape.

Create it after a bulk load. Bulk import does not maintain edge-sorted indexes incrementally; CREATE EDGE SORTED INDEX after the load backfills the whole graph in one pass, and every write from then on keeps it live.

Skip it when the fan-out is small and uniform (a plain traverse-then-sort is already cheap), when you never LIMIT the result (you want all neighbours in order — there is no top-N to accelerate), or when the target property changes very frequently on high-degree anchors (see the write-cost note below).

Advisor: discovering you need one

You do not have to guess whether a query would benefit. patinaDB ships an advisor that recognises the traversal-fan-out + ORDER BY target.prop LIMIT shape and, when it is running the slow fan-out top-K only because no covering edge-sorted index exists, tells you the exact statement to create. It is advice only — it never creates anything and never changes how a query runs.

In EXPLAIN / PROFILE. When the plan falls onto the fan-out limit.bounded_topk for a coverable shape, the plan text carries an Advice: line naming the index that would flip it to limit.edge_sorted_topk:

EXPLAIN MATCH (u:User {uid: 1})-[:REPORTED]->(t:Ticket)
        RETURN t ORDER BY t.created_at DESC LIMIT 20
…
Physical:
  limit.bounded_topk
Advice: CREATE EDGE SORTED INDEX FOR ()-[:REPORTED]->(m:Ticket) ON m.created_at  (would serve this ORDER BY … LIMIT via edge_sorted_topk instead of a fan-out top-K)

Run that CREATE EDGE SORTED INDEX and the advice disappears — the plan now reads limit.edge_sorted_topk. That round-trip is the guarantee: an advice is emitted iff creating the named index would actually engage the fast path (it reuses the executor’s own coverage decision), so there are no false positives.

As a procedure. CALL patinadb.advisor(query) analyses an arbitrary query string and returns one row per suggestion:

CALL patinadb.advisor(
  'MATCH (u:User {uid: 1})-[:REPORTED]->(t:Ticket) RETURN t ORDER BY t.created_at DESC LIMIT 20'
) YIELD suggestion, reason, current_plan
columnmeaning
suggestionthe exact CREATE EDGE SORTED INDEX … statement to run
reasonwhy it is suggested (the shape it matched, the plan it runs today)
current_planthe plan the query uses right now (limit.bounded_topk)

It returns no rows (no error) when there is nothing to suggest: a non-traversal query, a shape an edge-sorted index cannot serve (variable-length, undirected, multi-anchor, no ORDER BY … LIMIT, a filtered/unlabeled target), or a query already served by a covering index. Use it to sweep your hot read queries and discover the exact indexes to declare.

Honest positioning and tradeoffs

How this compares to other graph databases. General-purpose graph databases, Neo4j included, index a relationship’s own properties, or a node’s properties — not a per-anchor adjacency pre-sorted by a neighbour’s property. For the top-N-neighbours-by-neighbour-property shape that means the usual plan is traverse-then-sort, exactly the cost this index removes. The underlying idea of a sorted adjacency is not new — bespoke feed and social-graph stores have long kept time-sorted association lists for precisely this access pattern — but exposing it as a declarative, general-purpose index you can CREATE over any (rel, target-property) pair, inside a Cypher database, is uncommon. That is the differentiator, stated plainly.

Write cost. The index is maintained synchronously, and the cost is not free:

  • Creating or deleting an edge updates one index entry — cheap.
  • Changing a target’s sort property moves that target in every anchor’s slice that points at it. For a target with high in-degree (many anchors point to it), a single SET t.created_at = … is O(in-degree) index moves. If your workload rewrites the sort property often on well-connected targets, weigh that against the read speedup.

Snapshot-carry caveat (clustered mode). In a Raft cluster the index definitions replicate via the Raft log — every node re-runs the DDL and builds its own copy, deterministically. They are not yet carried in Raft snapshots. A node that bootstraps purely from a streamed snapshot (after the log that carried the CREATE was purged) will lack the def and quietly fall back to traverse-then-sort until the DDL is re-issued. Correctness is never affected — only speed — and the fallback path returns identical rows. Re-issue the CREATE EDGE SORTED INDEX statements after such a bootstrap (they are idempotent) to restore the fast path. This is tracked in Limitations.

Query Planning & Performance

patinaDB compiles each query into a streaming operator tree (the “Op tree”), picks a physical access path for every entry point, and — on skewed data — uses a statistics catalog and a cost model to choose between competing plans. This chapter shows how to read what the planner chose and which fast paths make a query cheap.

Reading EXPLAIN and PROFILE

EXPLAIN renders the chosen plan without running the query; PROFILE runs it and prefixes the elapsed time.

EXPLAIN MATCH (p:Person {name: 'Ada'}) RETURN p
plan: 'Project items=1 distinct=false return_star=false
  MatchScan required=[[entry](p:Person) → PropertyIndex[name] = est.1]
'

Read the tree bottom-up: the MatchScan is the leaf that produces rows, and each line above consumes them. The arrow annotation is the access path and its estimated cardinality — here a PropertyIndex[name] = est.1 point lookup on the value index (one estimated row), not a full label scan.

A range predicate with an ORDER BY that a value index can serve turns into a sorted seek and emits a Physical: footer naming the executed access path:

EXPLAIN MATCH (r:Reader) WHERE r.age > 40 RETURN r.name ORDER BY r.age
plan: 'Project items=1 distinct=false return_star=false
  MatchScan required=[[entry](r:Reader) → PropertyIndex[age] SORTED SCAN ASC est.2]
Physical:
  entry.range_seek
'

The Physical: footer is the single source of truth for the executed access path — the same registry drives both the EXPLAIN render and the executor, so EXPLAIN cannot lie about which path runs. Common footer names:

FooterMeaning
entry.range_seekValue-index range seek (aligned >/>=/</<= bound).
entry.cost_override: <rule> (≈N rows) beats <priority-rule> on costThe cost model overrode the default priority pick (see below).
limit.bounded_topkORDER BY … LIMIT k via an O(k)-memory top-K heap (no full sort).
limit.edge_sorted_topkTraversal + ORDER BY target.prop LIMIT k served by an edge-sorted index.

The statistics catalog

The planner keeps a lazy, cached statistics catalog derived from the value index — per label a vertex count, and per property the present count, distinct-value count (NDV), min/max, and a small equi-depth histogram. Inspect it with CALL patinadb.stats:

CALL patinadb.stats('Person')

For a small Person dataset (3 nodes) this yields, per property:

propertycountndvminmax
(label)3
active32falsetrue
age333240
name33AdaGrace

The catalog is invalidated automatically by writes (it is tagged with each label’s write generation), computed on demand, cached, and — because statistics only change which plan runs, never the resultnever replicated across a cluster. Each node computes its own.

Cost-based selection

Two things use the catalog:

  1. Entry-point selection. For a skewed EQ predicate (a value with many rows) or a range predicate, the estimator tightens its guess using the catalog (present_count / NDV, or histogram interpolation) instead of a pessimistic whole-label count. That can move the entry point of a multi-node pattern to the genuinely cheapest node.

  2. Physical-rule ranking. When both a covering compound index and a selective single-property seek apply to the same node, the planner ranks them by an estimated Cost { rows, io } rather than static priority — so a non-selective compound index correctly loses to a selective single-property seek. When the cost model overrides the default pick, EXPLAIN’s footer says so (entry.cost_override: …).

  3. Join ordering. A multi-pattern comma-MATCH is reordered so the most selective pattern drives the join and the intermediate result stays small (a bounded Selinger DP for ≤ 8 patterns, greedy above that). Connectivity is preserved, so the reorder never introduces a cartesian product the written order avoided.

Because an inner join’s result is a multiset independent of join order, and a different access path over the same data returns the same rows, cost-based planning changes only speed, never results. A query without ORDER BY already returns rows unordered; one with ORDER BY post-sorts.

Fast paths that make queries cheap

The executor recognizes a number of shapes and serves them without a full scan or a blocking sort. You don’t opt into these — they fire automatically when the shape and the available indexes match. The Physical: footer tells you which fired.

ShapeFast path
MATCH (n:L {p: v})Value-index point lookup (PropertyIndex[p] =).
WHERE n.p IN [...]IN-list seek — a union of point seeks, not a scan + filter.
WHERE n.p STARTS WITH 's'Prefix-range scan over the order-preserving string index.
WHERE k1=X AND k2 > Y ORDER BY k2 (compound (k1,k2))Compound range-seek to the range boundary.
ORDER BY p [DESC] LIMIT k (index-served)O(log N + k) sorted scan, LIMIT pushed in.
ORDER BY p [DESC] SKIP n LIMIT kDeep-SKIP key-only cursor advance (no per-skipped-row fetch).
WHERE p > $cursor ORDER BY p LIMIT kKeyset paginationO(log N + k) value-cursor seek.
ORDER BY a, b LIMIT kCovering-compound or leading-key partial-prefix scan (no post-sort).
ORDER BY … LIMIT k (not index-served)Bounded top-K heap — O(k) memory, not a full sort.
traversal + ORDER BY target.prop LIMIT kEdge-sorted top-K when a covering index exists.
[NOT] EXISTS { (n)-[:R]->() }Bare pattern-existence probe — one edge-index seek, no sub-plan.
RETURN n.p, count(*) grouped by n.pGroup-by-count run-length value-index scan.
WHERE distance(n.p, c) < D / withinBBox(...)Spatial curve-range seek (entry.spatial_seek) when a point index exists.
ORDER BY distance(n.p, c) LIMIT kSpatial kNN expanding-ring search over a point index.

The advisor

When a traversal + ORDER BY target.prop LIMIT k shape could be served by an edge-sorted index but none exists, both EXPLAIN and the advisor procedure tell you exactly which index to create:

EXPLAIN MATCH (c:Company {name:'Acme'})<-[:WORKS_AT]-(p:Person)
RETURN p ORDER BY p.age DESC LIMIT 3
plan: 'Limit skip=0 count=3
  Project items=1 distinct=false return_star=false
    Sort keys=1
      MatchScan required=[(c:Company) -[entry](p:Person) → PropertyIndex[age] SORTED SCAN DESC est.3]
Physical:
  limit.bounded_topk
Advice: CREATE EDGE SORTED INDEX FOR (m:Person)<-[:WORKS_AT]-() ON m.age  (would serve this ORDER BY … LIMIT via edge_sorted_topk instead of a fan-out top-K)
'

The same suggestion is available programmatically:

CALL patinadb.advisor(
  'MATCH (c:Company {name:"Acme"})<-[:WORKS_AT]-(p:Person) RETURN p ORDER BY p.age DESC LIMIT 3'
) YIELD suggestion
suggestion: 'CREATE EDGE SORTED INDEX FOR (m:Person)<-[:WORKS_AT]-() ON m.age'

Creating that index flips the plan to limit.edge_sorted_topk and the advice disappears. See Edge-Sorted Indexes.

Bounded-memory guards

Blocking operators (aggregate input, non-top-K ORDER BY, UNION-distinct, and hash-join build sides) are capped at PATINADB_MAX_AGG_ROWS (default 5,000,000). Past the cap they raise a clear, actionable error instead of an OOM — the default is far above any normal query, so below it the result is byte-identical. See Configuration and Caching & Memory Tuning for the RAM budget model, and Cache Observability & Tuning for the read-cache hit rates that make repeated hot queries cheap.

Procedures (CALL)

patinaDB has an extensible procedure framework. Procedures are invoked with CALL, declare a fixed set of named yield columns, and stream rows back into the query like any other operator. A trailing CALL with no RETURN implicitly returns all yielded columns.

CALL patinadb.engrams() YIELD id, message, timestamp
RETURN id, message ORDER BY timestamp DESC

You can register your own read-only WASM procedures — see WASM Procedures below. Everything under Built-in procedures is a built-in, always-available procedure; user-defined ones are called as CALL wasm.<name>(...) after a CREATE FUNCTION … LANGUAGE wasm DDL statement.

Bulk write clause: CALL { … } IN TRANSACTIONS

A sibling of CALL worth knowing alongside the procedures below: wrapping a mutating subquery in CALL { … } IN TRANSACTIONS [OF n ROW[S]] [ON ERROR {CONTINUE|BREAK|FAIL}] chunks its writes into many small, independently committed transactions instead of one giant one — the batched-ingest answer to a LOAD CSV … CREATE memory cliff.

LOAD CSV WITH HEADERS FROM 'file:///data/accounts.csv' AS row
CALL {
  WITH row
  CREATE (:Account {id: toInteger(row.id), name: row.name})
} IN TRANSACTIONS OF 1000 ROWS

Each chunk is its own engram and, on a cluster, its own Raft entry (⌈rows/n⌉ bounded entries, not one unbounded one); a later chunk sees the writes of earlier ones (read-your-writes). ON ERROR FAIL (default) aborts the whole statement on a failing chunk; CONTINUE skips it and keeps going; BREAK stops after it, keeping earlier chunks. It must be the query’s final clause, and cannot run inside an explicit Bolt BEGIN … COMMIT. See Bulk Loading & Import.

User-defined procedures: WASM functions

Beyond the built-ins below, you can register your own read-only procedures compiled to WebAssembly:

CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';
CALL wasm.fib(10) YIELD result RETURN result;  -- 55

A WASM function runs deterministically and sandboxed (no filesystem, network, clock, or randomness), which is what makes it safe to replicate across a cluster — CREATE/DROP FUNCTION are DDL that replicate by value (compiled module bytes, not the source path) and ride Raft snapshots. See WASM Procedures for the full registration grammar, the ABI, and the replication/RBAC model.

Built-in procedures

History & diff

ProcedureYieldsPurpose
patinadb.engrams()id, message, timestamp, …List committed engrams (newest first).
patinadb.diff(id)per-operation rowsGit-style view of a single engram.
patinadb.diffRange(from, to)structural change rowsMove-aware structural diff between two points.

See Engrams and Diffs.

Schema introspection

ProcedureYieldsDescription
db.labels()labelEvery distinct node label in the graph (primary and secondary).
db.relationshipTypes()relationshipTypeEvery distinct relationship type.
db.propertyKeys()propertyKeyEvery distinct property key across nodes and relationships.

These are read-only and run over every surface (REST, Bolt) via the same CALL path — not a driver/browser-only shim.

Registered under two namespaces, like full-text search:

ProcedureAliasYieldsDescription
db.index.vector.queryNodes(name, k, queryVector)patinadb.vector.queryNodesnode, scorek-nearest-neighbour search over a vector index (IVF-Flat ANN, cosine or Euclidean similarity). Yields real nodes.

See Vector Search for index creation, the similarity functions, and the ANN accuracy trade-off.

Registered under two namespaces — the Neo4j-compatible name and a patinaDB alias — so existing Neo4j tooling works unchanged:

ProcedureAliasYields
db.index.fulltext.queryNodes(name, query)patinadb.fulltext.queryNodesnode, score
db.index.fulltext.queryRelationships(name, query)patinadb.fulltext.queryRelationshipsrelationship, score

queryNodes yields real nodes (not just property maps), so they render as graph nodes in the Neo4j Browser and hydrate correctly through Bolt drivers:

CALL db.index.fulltext.queryNodes('docs', 'graph AND database~')
YIELD node, score
RETURN node.title, score ORDER BY score DESC LIMIT 10

See Full-Text Search for index creation and the query syntax.

Graph algorithms

A small set of read-only graph algorithms run over an in-memory snapshot of the current graph. Each takes an optional node-label and relationship-type projection (pass null to include everything) and yields real nodes plus a per-node result. gds.*.stream aliases are provided for tooling discoverability — note they use patinaDB’s simplified (label?, relType?, config?) signature, not Neo4j GDS’s named-graph-projection API.

ProcedureAliasYieldsPurpose
patinadb.algo.pageRank(label?, relType?, config?)gds.pageRank.streamnode, scoreIterative PageRank. config = {iterations: 20, dampingFactor: 0.85}. Scores sum to ≈ 1.0.
patinadb.algo.wcc(label?, relType?)gds.wcc.streamnode, componentIdWeakly-connected components. componentId is the smallest node UUID in the component (stable across runs).
patinadb.algo.degree(label?, relType?, config?)gds.degree.streamnode, scoreDegree centrality. config = {direction: 'both'} (in/out/both); a self-loop counts twice for both.
patinadb.algo.betweenness(label?, relType?)gds.betweenness.streamnode, scoreExact betweenness centrality (Brandes’ algorithm) over directed shortest paths. A path’s interior nodes score highest; O(V·E).
patinadb.algo.closeness(label?, relType?)gds.closeness.streamnode, scoreCloseness centrality over directed shortest paths, Wasserman–Faust normalized ((k/(N-1))·(k/Σd)) so disconnected graphs are well-defined; unreachable-only nodes score 0. O(V·E).
patinadb.algo.triangleCount(label?, relType?)gds.triangleCount.streamnode, trianglesPer-node triangle count over the undirected simple graph (self-loops/parallel edges collapsed). Global count = Σ triangles / 3.
patinadb.algo.labelPropagation(label?, relType?, config?)gds.labelPropagation.streamnode, communityIdDeterministic synchronous label-propagation community detection. config = {maxIterations: 10}. communityId = the smallest node UUID in the community (stable across runs, like WCC).
CALL patinadb.algo.pageRank('Page', 'LINKS', {iterations: 30})
YIELD node, score
RETURN node.title, score ORDER BY score DESC LIMIT 10

The algorithms materialize the projected graph in RAM (fine up to ~10M edges on one box), run in memory, and yield without mutating anything — so they need no replication and can run on any cluster node. Because PageRank is normalized to sum to 1, its ranking matches Neo4j but its magnitudes differ.

betweenness and closeness follow out-edges (directed shortest paths), matching PageRank/degree; triangleCount and labelPropagation treat edges as undirected. Every algorithm is deterministic — the crux for labelPropagation, which is normally randomized: patinaDB uses a fixed synchronous update with a lowest-label tie-break and a hard iteration cap (iterations/maxIterations are bounded at 1000 so a read call cannot become a compute DoS), so every cluster node computes the same communities. Because betweenness/closeness are O(V·E), they suit small-to-medium projections; the in-memory-only scale ceiling applies. APOC utility procedures and weighted/personalized PageRank variants are planned follow-ons.

Statistics catalog

ProcedureYieldsDescription
patinadb.stats(label?)label, property, count, ndv, min, maxThe query planner’s statistics catalog for a label (or all labels). One summary row per label (property = null, count = vertex count) plus one row per property with its present count, distinct-value count (NDV), and min/max.
CALL patinadb.stats('Person')
YIELD label, property, count, ndv, min, max
RETURN property, count, ndv, min, max ORDER BY ndv DESC

The catalog feeds cost-based entry-point selection: it lets the planner estimate how selective a WHERE/pattern filter is (e.g. a rare property value vs a common one) and drive a query from the cheapest starting point. It also feeds cost-based join ordering: a multi-pattern MATCH (a)…, (b)…, (c)… is reordered so the most selective pattern drives the join and intermediate results stay small, instead of joining patterns in written order. Statistics are local performance hints — they change only which plan runs, never the result (a different join order returns the same rows) — so they are computed lazily per node, cached, and never replicated.

Index advisor

ProcedureYieldsDescription
patinadb.advisor(query)suggestion, reason, current_planAnalyze a query string (parsed + planned exactly as EXPLAIN) and suggest an index that would flip it to a faster physical plan. Today it surfaces missing edge-sorted indexes for a traversal + ORDER BY target.prop LIMIT k shape; empty result means nothing to advise.
CALL patinadb.advisor(
  'MATCH (c:Company {name:"Acme"})<-[:WORKS_AT]-(p:Person) RETURN p ORDER BY p.age DESC LIMIT 3')
YIELD suggestion, reason, current_plan RETURN suggestion
-- suggestion: 'CREATE EDGE SORTED INDEX FOR (m:Person)<-[:WORKS_AT]-() ON m.age'

EXPLAIN prints the same advice as an Advice: footer. See Query Planning & Performance and Edge-Sorted Indexes.

Cache observability

ProcedureYieldsDescription
patinadb.cache.stats()scope, kind, bytes, entries, hit_rate, generationThe multi-level, RAM-budget-governed cache. One row per (level, scope): scope is the hot database / collection (label) / query shape (db:1/label:Ticket), kind is the cache level, and hit_rate is hits / (hits + misses). generation is reserved (yielded as null today).
CALL patinadb.cache.stats()
YIELD scope, kind, bytes, entries, hit_rate
RETURN scope, kind, bytes, hit_rate ORDER BY bytes DESC

Read-only and node-local. When caching is disabled (the default) it yields zero rows (no error). The same view is available over HTTP as GET /mgmt/cache and as Prometheus gauges on /metrics. For every metric and a tuning playbook see Cache Observability & Tuning.

CSV export

Write graph data out as neo4j-admin-style CSV — the same format GET /mgmt/export uses, so an exported file loads straight back with LOAD CSV/bulk upsert (:ID(uuid) reproduces vertex UUIDs, byte-identical).

ProcedureAliasYieldsDescription
patinadb.export.csv(label, path [, config])file, kind, rowsExport one label’s vertices to CSV. path ending .csv writes a single node file; otherwise path is a directory and gets nodes_<Label>.csv plus that label’s outgoing relationships as rels_<TYPE>.csv (one per edge type). config = {rels: false} skips relationships.
patinadb.export.query(query, file)apoc.export.csv.queryfile, kind, rowsExport an arbitrary query result (selective export). CSV columns are the RETURN names.
-- Whole label + its relationships into a directory:
CALL patinadb.export.csv('Person', '/data/exports/people')
YIELD file, kind, rows RETURN file, kind, rows

-- A selective slice via a query:
CALL patinadb.export.query(
  'MATCH (p:Person) WHERE p.age > 30 RETURN p.name AS name, p.age AS age',
  '/data/exports/over30.csv')

Node files carry an :ID(uuid) column, one typed column per property (name:int / :float / :boolean / :date / :localdatetime, or a bare string column) and a :LABEL column; an absent property is a blank cell. Relationship files carry :START_ID(uuid) / :END_ID(uuid) / :TYPE + typed property columns. Query-export entity cells (nodes/relationships) are rendered as their UUID / type — use patinadb.export.csv for a structural round-trip. Export streams row-by-row (bounded memory) over the current committed graph.

Security note. These procedures write a file on the machine running the query — the same file-write consideration as LOAD CSV reading a file:// URL. On the server both are deny-by-default: an export writes only to directories whitelisted with --allow-export-dir, and any file-I/O query is raised to require the global Admin role (details). The procedures are CSV-only; Parquet/Arrow export goes through GET /mgmt/export?format=parquet|arrow (see REST API).

Availability across interfaces

Procedures work identically over REST and Bolt (including the Neo4j Browser) — the same CALL dispatch serves both. Full-text and vector query procedures read from the local node’s copy of the index, so reads are served without a round trip to the leader.

WASM Procedures (User-Defined Functions)

patinaDB lets you register your own read-only procedures compiled to WebAssembly — callable from Cypher as CALL wasm.<name>(args), just like a built-in procedure. This is patinaDB’s user-defined-function story: instead of an ad-hoc scripting language, a UDF is a compiled .wasm module, which means it can be written in any language that compiles to WebAssembly (Rust, C/C++, AssemblyScript, Zig, …) and runs deterministically and sandboxed — the property that makes it safe to replicate to every node in a cluster.

CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';

CALL wasm.fib(10) YIELD result RETURN result;
-- result: 55

Why WASM

Three engines were evaluated (Rhai, LuaJIT, WASM/wasmtime) for embeddable user-defined logic. WASM won on both axes that matter for a replicated database:

  • Speed. Near-native on compute, and the fastest scripted call of the three.
  • Determinism by construction. A WASM guest module can be sandboxed so it imports nothing from its host — no clock, no RNG, no filesystem, no network. With host imports denied, the same module given the same input produces the same output on every run and every node, every time. That is the property a Raft-replicated write path requires, and it is why patinaDB doesn’t offer a general scripting language as its UDF surface.

A WASM function is currently read-only: pure compute over its scalar arguments, plus (as of increment 2) bounded point-reads against the graph. It cannot write, and cannot reach the filesystem, network, clock, or any randomness source.

Registering a function

CREATE FUNCTION [IF NOT EXISTS] <name> [(<arity>)] LANGUAGE wasm
  FROM '<file-url>' EXPORT '<entry-export-name>'

DROP FUNCTION [IF EXISTS] <name>

SHOW FUNCTIONS
  • <name> is a bare identifier (or a backtick-quoted one) and becomes callable as CALL wasm.<name>(...).
  • <arity> is optional and documents the expected argument count; it’s not currently enforced beyond what the guest’s ABI decoding does.
  • LANGUAGE wasm is required — it’s the only language patinaDB currently compiles, so this is future-proofing the grammar rather than a real choice today.
  • FROM '<file-url>' is a file:// URL. The module’s bytes are read once, at CREATE FUNCTION time, compiled, validated (see Sandbox & determinism below), and then stored — the file itself is never consulted again. This matters for two reasons: a reopened database re-activates the function with no dependency on the original file still existing, and on a cluster the compiled module’s bytes (not the path) are what gets replicated — a follower obviously can’t read a file that only exists on the leader’s disk.
  • EXPORT '<entry-export-name>' names the guest function to call — see the ABI section below for what that function must look like.
  • A module that fails to compile, imports anything not on the allowed list (see below), or doesn’t export the required ABI surface, is rejected at CREATE FUNCTION time with a clear error, and nothing is persisted.

DROP FUNCTION removes the definition and makes wasm.<name> uncallable. SHOW FUNCTIONS lists what’s registered.

A worked example

Here is a minimal, complete fib guest written directly in WebAssembly text format (WAT) — small enough to read end to end, and a realistic shape for whatever toolchain compiles your language of choice to .wasm. Save it as fib.wat, assemble it to fib.wasm with wat2wasm (from WABT), and register it:

(module
  (memory (export "memory") 1)
  (global $bump (mut i32) (i32.const 1024))

  ;; A bump allocator — the host uses this to hand the guest its own args.
  (func $alloc (export "alloc") (param $n i32) (result i32)
    (local $p i32)
    (local.set $p (global.get $bump))
    (global.set $bump (i32.add (global.get $bump) (local.get $n)))
    (local.get $p))

  ;; fib(n): Int -> Int
  (func (export "fib") (param $in i32) (param $len i32) (result i64)
    (local $n i64) (local $a i64) (local $b i64) (local $t i64) (local $i i64)
    (local $out i32)
    (local.set $n (i64.load align=1 (i32.add (local.get $in) (i32.const 5))))
    (local.set $a (i64.const 0))
    (local.set $b (i64.const 1))
    (local.set $i (i64.const 0))
    (block $done
      (loop $loop
        (br_if $done (i64.ge_s (local.get $i) (local.get $n)))
        (local.set $t (i64.add (local.get $a) (local.get $b)))
        (local.set $a (local.get $b))
        (local.set $b (local.get $t))
        (local.set $i (i64.add (local.get $i) (i64.const 1)))
        (br $loop)))
    (local.set $out (call $alloc (i32.const 9)))
    (i32.store8 (local.get $out) (i32.const 1))
    (i64.store align=1 (i32.add (local.get $out) (i32.const 1)) (local.get $a))
    (i64.or (i64.shl (i64.extend_i32_u (local.get $out)) (i64.const 32)) (i64.const 9))))
CREATE FUNCTION fib (1) LANGUAGE wasm FROM 'file:///opt/patinadb/fib.wasm' EXPORT 'fib';

CALL wasm.fib(10) YIELD result RETURN result;
-- result: 55

CALL wasm.fib(20) YIELD result RETURN result;
-- result: 6765

If you’re writing the guest in Rust instead of hand-rolled WAT, the same ABI applies: export memory, alloc, and your entry function with the exact signatures in The ABI below; the wasm32-unknown-unknown target with #[no_std] (or a thin marshalling shim over std) compiles cleanly to a module with no imports.

Reading the graph from a function (increment 2)

A WASM function isn’t limited to its scalar arguments — it can also do bounded point-reads against the graph it’s called from, over a consistent snapshot of the database’s current committed state (HEAD). This lets a function compute over a node’s properties, labels, or neighbours without patinaDB having to expose general graph traversal to the sandbox.

A guest opts in by importing exactly these three host functions from the module "patinadb" (any other import — a WASI clock, env anything — is rejected at CREATE FUNCTION time):

Host functionSignatureReturns
node_get_property(id_ptr, prop_ptr, prop_len) -> i64The property’s value (Null if absent).
node_labels(id_ptr) -> i64A List of the node’s labels as strings.
node_neighbors(id_ptr, dir, rel_ptr, rel_len) -> i64A List of neighbouring node UUIDs. dir: 0=out, 1=in, 2=both. rel_len=0 means any relationship type.

Call one of these by passing a node argument through to your entry function — CALL wasm.readScore(n) evaluates n to the node’s UUID, which the entry function then hands to node_get_property. See crates/patinadb-wasm/examples/guest.wat in the repository for a complete reference guest that imports all three.

Bounded, so a function can’t read the whole graph unboundedly: each host call counts against a per-invocation budget (PATINADB_WASM_MAX_GRAPH_READS, default 100,000 calls) — once exceeded the guest traps with a clean error, never a hang. node_neighbors is additionally capped at a maximum number of returned neighbour ids per call. A malformed request (an out-of-bounds pointer, an unknown direction, or calling these outside of a query context) is also a clean trap, never a panic or a crash.

Still read-only: none of these host functions can create, update, or delete anything.

Determinism & the sandbox

Everything above rests on one guarantee: the same module given the same input produces the same output, everywhere, every time. patinaDB enforces this at the engine level, not by convention:

  • No host imports beyond the documented allow-list. A guest module is instantiated with an empty import list by default; the only imports ever accepted are the three graph-read functions above, and only when a graph context is available. Anything else — a WASI clock, a random-number source, a filesystem call, an environment-variable read — is rejected at registration, before the module is ever run.
  • Fuel is the deterministic resource bound. Every invocation gets a fixed instruction budget (100,000,000 units by default). The same module and input consume the same fuel and trap at the same point on every node — this is what makes an accidental infinite loop safe to replicate rather than a cluster-wedging hang.
  • Wall-clock (epoch) and memory are safety nets, not the primary bound. A 5-second wall-clock deadline and a 16 MiB linear-memory cap protect a single node against a runaway guest, but neither is used to gate a replicated write, because wall-clock timing is inherently nondeterministic across machines.
  • Strict IEEE-754 floating point, with no relaxed-SIMD and NaN canonicalization enabled, so floating-point results are bit-identical across platforms too.

A guest that traps (fuel exhausted, memory cap hit, deadline exceeded, out-of-bounds access, or an explicit guest panic) surfaces as a clean query error — never a process crash or a hang.

Because of this sandboxing, patinadb-wasm is an isolated, opt-in crate: core patinadb has no dependency on it and pulls in no WebAssembly runtime by default. A server or embedding application opts in explicitly (see below); a plain build of the core library is unaffected either way.

Running on the server (replication & RBAC)

CREATE/DROP FUNCTION are DDL, and on a clustered server they behave like any other schema change:

  • Requires the global admin role. CREATE FUNCTION reads a file off the leader’s local disk, so it’s authorized like LOAD CSV and the CSV export procedures — by effect, not by role level. A per-database Writer is refused; only a global Admin can register or drop a function. The file path is confined by the same --allow-csv-dir sandbox LOAD CSV uses (see Cypher-driven file I/O).
  • Replicated by value, not by path. The leader reads, validates, and compiles the module exactly once, then proposes the resulting definition — module bytes included — as a single Raft log entry. Every node applies it identically: persists the definition and, if it has WASM support installed (see below), activates it. A node without WASM support still stores the definition (so it stays consistent and forwards writes correctly) but can’t execute the function locally.
  • Snapshot-carried. A node that bootstraps purely from a streamed Raft snapshot — with the original CREATE FUNCTION log entry long since purged — still ends up with every registered function, because the snapshot itself carries the compiled module bytes.
  • Module-size cap. A registered module is capped at PATINADB_MAX_WASM_MODULE_BYTES (default 8 MiB) so a large module can’t blow past the Raft peer-RPC body-size limit. CREATE FUNCTION fails loudly, before anything is proposed, if the module is over the cap.

A server enables this feature with one line at startup (patinadb_wasm::install(), already wired into the shipped patinadb-raft binary) plus a build-time dependency on the patinadb-wasm crate — core patinadb remains WASM-free either way.

The ABI (for guest authors)

A guest module must export:

  • memory — its linear memory.
  • alloc(len: i32) -> i32 — allocate len bytes and return the pointer. Any allocation strategy works (the reference guest above uses a trivial bump allocator); the host calls this to place argument bytes into the guest’s memory before invoking the entry function, and the guest calls it itself to place its result.
  • The entry function named in EXPORT '<name>', with signature (in_ptr: i32, in_len: i32) -> i64. The return value packs a pointer and a length into one 64-bit integer: (out_ptr as u64) << 32 | out_len as u64.

Wire format — a small, frozen, tag-prefixed binary encoding, deliberately not patinaDB’s internal storage format, so it stays a stable, language-agnostic contract for any guest toolchain:

  • The argument blob at in_ptr is u32 count (little-endian) followed by count tag-prefixed values.

  • The result blob the guest allocates and returns is exactly one tag-prefixed value.

  • Each tagged value is one byte for the tag, then the payload:

    TagTypePayload
    0Null(none)
    1Integeri64, little-endian
    2Floatf64 bits, little-endian
    3Boolone byte, 0/1
    4Stringu32 length + UTF-8 bytes
    5Listu32 count + that many tagged values
    6Uuid16 raw bytes (a node/relationship handle)
    7Mapu32 count + that many (u32 key-len + key bytes + tagged value) entries
    8Nodea Uuid + a labels-List + a properties-Map
    9Relationshipa start Uuid + an end Uuid + a type-String + a properties-Map

A Cypher value outside this set — a temporal value, a spatial Point/Polygon, or a multi-hop Path — is rejected loudly as an unsupported argument/result type rather than silently coerced.

Limitations

  • Read-only. A WASM procedure cannot create, update, or delete graph data.
  • Not a scalar UDF (yet). A WASM function is a procedure, called with CALL wasm.<name>(...) YIELD result — it does not (yet) compose into an arbitrary expression position like RETURN f(n.x) or a WHERE clause.
  • No filesystem, network, clock, or randomness inside the guest, ever — this is the determinism guarantee, not a configurable option.
  • The scalar/entity ABI subset above is everything. Temporal values, spatial Point/Polygon, and multi-hop paths are not (yet) marshalled across the boundary.
  • Graph reads are single-vertex point-reads and bounded neighbour lists — there’s no general traversal or query-execution host call from inside a guest.
  • CREATE EDGE SORTED INDEX-style module toolchains aren’t required or assumed — you bring your own WASM compiler; patinaDB only defines the ABI a compiled module must satisfy.

The full design write-up — including the alternatives considered and the increment-by-increment implementation history — lives in the repository’s docs/rfcs/0017-wasm-embedded-procedures.md.

Full-Text Search

patinaDB supports user-defined full-text indexes with BM25 ranking, using Neo4j-compatible syntax. You create an index over chosen string properties of a label (nodes) or relationship type (edges), then query it through the full-text procedures, getting back ranked entities and relevance scores.

Creating an index

-- Over node properties
CREATE FULLTEXT INDEX docs FOR (n:Doc) ON EACH [n.title, n.body]

-- Over relationship properties
CREATE FULLTEXT INDEX mentions FOR ()-[r:MENTIONS]-() ON EACH [r.context]

-- With an analyzer
CREATE FULLTEXT INDEX articles FOR (n:Article) ON EACH [n.body]
OPTIONS { indexConfig: { `fulltext.analyzer`: "english" } }
  • Only string properties are indexed (matching Neo4j). Non-string values on the listed properties are ignored.
  • The index is maintained synchronously: it is updated as part of every committed write, so it is never stale on read (no eventual consistency).

Analyzers

The analyzer controls tokenization and stemming:

AnalyzerBehaviour
standardLowercase, tokenize on non-alphanumerics. (Default.)
keywordTreat the whole value as a single token (exact-match indexing).
englishStandard + English Snowball stemming + English stop words.
germanStandard + German Snowball stemming + German stop words.

Managing indexes

SHOW FULLTEXT INDEXES
DROP INDEX docs

DROP INDEX <name> drops a full-text index by name. The Neo4j DROP INDEX ON :Label(prop) form (for ordinary indexes) is a different, unsupported statement and is left to the engine.

Querying

CALL db.index.fulltext.queryNodes('docs', 'graph database')
YIELD node, score
RETURN node, score ORDER BY score DESC

CALL db.index.fulltext.queryRelationships('mentions', '"exact phrase"')
YIELD relationship, score
RETURN relationship, score

The alias patinadb.fulltext.queryNodes / …queryRelationships is equivalent. Results are ranked by BM25 (k1 = 1.2, b = 0.75).

Query syntax (Lucene subset)

FormExampleMeaning
TermgraphDocuments containing the term.
Implicit ORgraph databaseAdjacent terms are OR’d.
AND / OR / NOTgraph AND NOT sqlBoolean operators; AND binds tighter than OR.
Phrase"graph database"Terms in that exact order (consecutive positions).
Prefixdata*Terms starting with data.
Fuzzydatabse~, db~2Edit-distance match (default / explicit distance).
Boostgraph^3 databaseMultiply a term’s contribution to the score.
Fieldtitle:graphRestrict a term to one indexed property.
Grouping(graph OR sql) AND dbParenthesised sub-expressions.

Prefix and fuzzy queries expand against the index dictionary and are capped at 256 expansions per term to bound cost.

In the server

CREATE FULLTEXT INDEX / DROP INDEX are replicated as Raft control commands (like CREATE DATABASE): the definition propagates to every node and each node builds the index from its own copy of the graph. Index definitions are carried in Raft snapshots and rebuilt on snapshot install, so a node that joins or restarts ends up with the same indexes. Query procedures read the local node’s index.

This works end-to-end over Bolt, including the Neo4j Browser: create an index and run queryNodes, and matching nodes come back as graph nodes with scores.

Limitations

  • No phrase slop / proximity ("a b"~3) — phrases must be exactly consecutive.
  • No highlighting / snippet extraction.
  • No numeric or range queries inside the full-text string (full-text indexes cover string properties only).
  • Postings are updated read-modify-write per document with no segment merging, so very high write throughput on a large indexed corpus is not the design target. See Limitations.

Vector Search

patinaDB supports vector / embedding search with Neo4j-compatible syntax, backed by an IVF-Flat (inverted-file, k-means clustering) approximate nearest-neighbour index. You store embeddings as list-of-float properties, create a vector index over them, then query it through the vector procedures, getting back ranked nodes and normalized similarity scores.

Storing vectors

A vector is an ordinary list-of-numbers property. Set it with plain Cypher:

CREATE (n:Product {id: 1, embedding: [0.12, -0.03, 0.88, 0.41]})
MATCH (n:Product {id: 1}) SET n.embedding = [0.10, -0.01, 0.90, 0.40]

When a vector index covers that (label, property), the index is maintained synchronously on every write — inserts, updates, and deletes are reflected immediately (no eventual consistency), on every replica.

Creating an index

CREATE VECTOR INDEX product_embeddings
FOR (n:Product) ON (n.embedding)
OPTIONS { indexConfig: {
  `vector.dimensions`: 4,
  `vector.similarity_function`: 'cosine'
} }
  • `vector.dimensions` (required) — the fixed vector length. Vectors of a different length, or non-numeric lists, are simply not indexed.
  • `vector.similarity_function`'cosine' (default) or 'euclidean'.
  • IF NOT EXISTS is supported.
  • Unknown option keys (e.g. Neo4j’s HNSW-specific params) are accepted and ignored, so existing Neo4j DDL runs unchanged.

IVF tuning (patinaDB extensions)

The index partitions vectors into nlist clusters (via k-means); a query scans the nprobe clusters nearest the query vector. Defaults: nlist = clamp(round(sqrt(count)), 1, 4096), nprobe = clamp(nlist / 16, 1, 64). Override them:

OPTIONS { indexConfig: {
  `vector.dimensions`: 384,
  `vector.similarity_function`: 'cosine',
  `patinadb.ivf.lists`: 256,
  `patinadb.ivf.nprobe`: 16
} }

Higher nprobe → higher recall, more work per query. nprobe = nlist scans every cluster (exact search).

Prefer building the index after loading data. The k-means centroids are trained from the vectors present at CREATE VECTOR INDEX time — training over an empty label produces an index with no centroids (there is nothing to cluster over an empty set). If that happens, patinaDB automatically retrains the index the next time a write touches that label, so it self-heals without needing a manual DROP/re-CREATE; the query that lands on the still-empty index right after CREATE (before the first later write) simply doesn’t match anything yet. Building after the data is loaded still avoids that retraining round-trip and remains the recommended order.

Managing indexes

SHOW VECTOR INDEXES
DROP VECTOR INDEX product_embeddings
DROP VECTOR INDEX product_embeddings IF EXISTS

SHOW VECTOR INDEXES yields name, label, property, dimensions, similarityFunction, lists, and nprobe.

Querying

CALL db.index.vector.queryNodes('product_embeddings', 5, [0.1, -0.02, 0.9, 0.4])
YIELD node, score
RETURN node, score ORDER BY score DESC

Arguments: the index name, the number of nearest neighbours k, and the query vector. Results are real graph nodes plus a score, ordered by descending similarity. The alias patinadb.vector.queryNodes is equivalent. A query vector whose length differs from the index’s dimensions is an error.

Similarity scalar functions

Two namespaced functions score a pair of vectors directly, using the same normalized formulas as the index score:

RETURN vector.similarity.cosine([1.0, 0.0, 0.0], [1.0, 0.0, 0.0])      -- 1.0
RETURN vector.similarity.euclidean([0.0, 0.0], [3.0, 4.0])            -- 1/26
  • vector.similarity.cosine(a, b) → Neo4j-normalized cosine (1 + rawCosine) / 2 ∈ [0, 1].
  • vector.similarity.euclidean(a, b)1 / (1 + euclideanDistanceSquared) ∈ (0, 1].

Both raise an error on a dimension mismatch or a non-numeric element, and propagate null when either argument is null.

In the server (High Availability)

CREATE / DROP VECTOR INDEX are replicated as Raft control commands. This is where the IVF choice matters:

patinaDB replicates a deterministic apply loop — every node applies the same operations and must converge to a byte-identical index, or the same query would return different results on different replicas. HNSW is randomized and insertion-order-dependent, so it would diverge. IVF-Flat’s k-means centroids are trained once on the leader and replicated as part of the index definition. Every node then assigns each vector to the nearest centroid by a pure, deterministic function → identical posting lists.

Index definitions (including their centroids) are carried in Raft snapshots, so a node that joins or restarts rebuilds identical posting lists from its restored graph. Ongoing writes are indexed synchronously on every node as they apply.

Works end-to-end over Bolt (including the Neo4j Browser): create an index and run db.index.vector.queryNodes, and matching nodes come back as graph nodes with scores.

How it works / limitations

  • IVF-Flat, not HNSW. Vectors are partitioned into nlist k-means clusters; a query scans only the nprobe clusters nearest the query, then computes the exact similarity to each candidate and keeps a bounded top-k. This is approximate: recall is tunable via nprobe (higher = better recall, more work; nprobe = nlist is exact).
  • Heavy one-time build. Assigning every existing vector to a centroid is O(N · nlist · dim) over the whole label — inherent to any ANN build. Incremental maintenance per write is cheap (one nearest-centroid assignment + a few KV ops).
  • Brute-force candidate scan. Within the probed clusters the candidate scan is a linear posting-list scan. It is deliberately isolated behind one internal function (VectorIndex::candidates), so a graph-based backend (e.g. HNSW per cluster) could replace it later without changing the query surface — but that backend would need to preserve cross-replica determinism.
  • Nodes only (v1). Vector indexes cover node properties; relationship vector indexes are not yet supported.
  • Build after loading (see the note under Creating an index).

Spatial / Geo

patinaDB has a first-class Point type with Neo4j’s four coordinate reference systems (CRSs), the point() / distance() / point.distance.ellipsoidal() / point.withinBBox() functions, and a CREATE POINT INDEX statement. Points are stored, indexed on disk with a space-filling-curve key, and queried correctly, and a planner fast path turns a radius/bbox query (and a kNN ORDER BY distance(...) LIMIT k) into a curve-range seek instead of a full scan.

The Point type

A point carries a CRS (identified by a Neo4j SRID) and 2 or 3 coordinates:

CRSSRIDDimCoordinates
cartesian72032[x, y]
cartesian-3d91573[x, y, z]
wgs-8443262[longitude, latitude]
wgs-84-3d49793[longitude, latitude, height]

For geographic (wgs-84) points, x is the longitude and y is the latitude (Neo4j’s convention), so p.latitude reads the second coordinate.

Two points are equal iff they have the same SRID and coordinates — a cartesian and a wgs-84 point are never equal even with identical numbers. < / > on points are undefined (they return null, matching Neo4j); ORDER BY uses a deterministic total order (the space-filling-curve order).

point({...}) — constructing points

CRS is inferred from the map keys, or set explicitly with crs / srid:

// cartesian (x/y) — 2D and 3D
RETURN point({x: 3.0, y: 4.0})
RETURN point({x: 1.0, y: 2.0, z: 3.0})

// geographic (latitude/longitude) — 2D and 3D
RETURN point({latitude: 52.52, longitude: 13.405})
RETURN point({latitude: 52.52, longitude: 13.405, height: 100.0})

// explicit override
RETURN point({x: 13.4, y: 52.5, crs: 'wgs-84'})
RETURN point({x: 1.0, y: 2.0, srid: 4326})

Out-of-range latitude (|lat| > 90) or longitude (|lon| > 180) raises an ArgumentError. point(null) returns null.

Points can be stored on nodes and relationships like any other property:

CREATE (:City {name: 'Berlin', loc: point({latitude: 52.52, longitude: 13.405})})

Accessors

Field access reads a coordinate or CRS component:

WITH point({x: 3.0, y: 4.0, z: 5.0}) AS p
RETURN p.x, p.y, p.z, p.crs, p.srid

MATCH (c:City)
RETURN c.loc.latitude, c.loc.longitude

.x/.longitude → axis 0, .y/.latitude → axis 1, .z/.height → axis 2, .crs → the CRS name string, .srid → the integer SRID. Accessing a missing component (e.g. .z on a 2-D point) returns null.

distance() / point.distance()

Both spellings work. Returns metres for geographic points (spherical Haversine) and Euclidean distance for cartesian points:

// Euclidean → 5.0
RETURN distance(point({x: 0, y: 0}), point({x: 3, y: 4}))

// Haversine, Berlin → Paris ≈ 878 km (metres)
RETURN point.distance(
  point({latitude: 52.52,  longitude: 13.405}),
  point({latitude: 48.8566, longitude: 2.3522})
)

// radius query (served by a curve-range seek when a POINT INDEX exists)
MATCH (c:City)
WHERE distance(c.loc, point({latitude: 52.5, longitude: 13.4})) < 5000
RETURN c.name

Mixed CRS (or mismatched dimensionality) returns null, matching Neo4j. wgs-84 distance is spherical (mean Earth radius 6 371 009 m), ~0.3 % off the true geoid — fine for radius search, not survey work.

point.distance.ellipsoidal() — accurate WGS-84 geodesic

When you need survey-grade accuracy, use the ellipsoidal variant, which computes the true WGS-84 geodesic distance via the Vincenty-inverse formula (accounting for the Earth’s oblateness). distance() stays spherical Haversine (Neo4j parity); point.distance.ellipsoidal() is the distinct, more-accurate spelling:

// WGS-84 ellipsoidal (Vincenty), Berlin → Paris ≈ 878 km — a distinct value
// from Haversine, but within 0.5 %.
RETURN point.distance.ellipsoidal(
  point({latitude: 52.52,  longitude: 13.405}),
  point({latitude: 48.8566, longitude: 2.3522})
)

Same argument conventions as distance(): null/non-point operand → null, mixed CRS/dimensionality → null. It is WGS-84 only — a cartesian argument falls back to plain Euclidean (there is no ellipsoid without a geoid). Near-antipodal pairs (where Vincenty does not converge) fall back to the always-finite spherical distance rather than emitting NaN. It is an exact scalar only — it does not drive the radius/bbox seek (which uses the spherical bounding math); a radius filter should still use distance().

point.withinBBox()

Bool — whether a point lies inside an axis-aligned bounding box:

MATCH (c:City)
WHERE point.withinBBox(c.loc,
        point({x: -1, y: -1}),
        point({x: 10, y: 10}))
RETURN c.name

A geographic box whose lowerLeft.longitude > upperRight.longitude wraps the antimeridian and is handled correctly — it covers [lowerLeft.longitude, 180] ∪ [-180, upperRight.longitude] rather than being rejected or silently returning false.

CREATE POINT INDEX

A point index is a planner-enablement marker. The on-disk curve keys are written for every point property unconditionally (see below), so registering an index is a lightweight no-op backfill; the def tells the planner it may use a curve-range seek for radius/bbox queries over that property (increment 3).

CREATE POINT INDEX city_loc [IF NOT EXISTS] FOR (n:City) ON (n.loc)
DROP POINT INDEX city_loc [IF EXISTS]
SHOW POINT INDEXES        -- also folds into SHOW INDEXES

Like every other index, it replicates across a Raft cluster (re-run deterministically on each node) and is carried in snapshots.

How points are stored (the curve key)

Every point property lands in the same label-scoped value index as every other scalar, under an order-preserving Morton / Z-order key (encode_for_index tag 0x07):

0x07 ++ srid(u32 BE) ++ morton_interleave(order-preserving axis codes) ++ exact axis codes

Each axis’s f64 runs through the same order-preserving u64 transform the Float index uses; the per-axis codes are bit-interleaved MSB-first into a fixed-width big-endian string (16 bytes for 2-D, 24 for 3-D) so byte order equals Z-curve order. The SRID leads the key, so points of different CRS occupy disjoint ranges. The exact coordinates are appended so the point decodes back exactly. A 2-D point key is 37 bytes. This is the final on-disk format — it ships in increment 1 so there is never a migration when the seek arrives.

Accelerating radius / bbox queries: the point-index seek

Create a point index so radius and bounding-box queries seek the Morton curve instead of scanning the whole label:

CREATE POINT INDEX loc_idx FOR (n:City) ON (n.loc)

Once the index exists, a query of the form

MATCH (c:City) WHERE distance(c.loc, point({latitude: 48.85, longitude: 2.35})) < 5000
RETURN c

or

MATCH (c:City) WHERE point.withinBBox(c.loc, point({x: 0, y: 0}), point({x: 10, y: 10}))
RETURN c

runs as a bounded set of curve-range seeks plus an exact post-filter: the query region is decomposed into a few contiguous Morton ranges (a superset of the answer), each is seeked in the value index, and the exact distance() / withinBBox filter trims the false positives. Results are identical to the full scan — the index only changes speed. EXPLAIN shows entry.spatial_seek in the Physical: footer when the seek is used. Without a point index the query still runs correctly, just as a label scan (Neo4j’s declare-to-accelerate model). A radius seek is roughly an order of magnitude faster than the full scan once the label is large and the region is selective.

Geographic edge cases are handled: a radius that crosses the ±180° antimeridian is split into two boxes and unioned; one that reaches a pole widens to all longitudes (a correct over-approximation the post-filter trims); an antimeridian-wrapping withinBBox (lower-left longitude greater than upper-right) covers [ll.lon, 180] ∪ [-180, ur.lon].

The seek also composes with a (non-kNN) ORDER BY — e.g.

MATCH (c:City) WHERE distance(c.loc, point({latitude: 48.85, longitude: 2.35})) < 5000
RETURN c ORDER BY c.name

still uses entry.spatial_seek to produce the candidate set, then applies the ORDER BY c.name as an ordinary post-sort over just those matches (byte-identical to a full scan + sort). Only a kNN ORDER BY distance(...) LIMIT k is served by its own path (above).

Nearest-neighbour (kNN)

MATCH (c:City) RETURN c ORDER BY distance(c.loc, point({latitude: 48.85, longitude: 2.35})) LIMIT 10

With a point index on c.loc, this runs an expanding-ring search over the curve: it grows a search box until the k-th nearest is provably confirmed (no un-searched point can be closer), then orders just that candidate set — identical to the full sort, but without touching every row. Without an index it falls back to the exact full sort.

Polygons & geometry (geometry MVP)

Beyond points, patinaDB has a first-class Polygon value and a small set of areal predicates — a scoped geometry MVP, not full PostGIS.

Constructing a polygon

// Exterior ring from a list of points (auto-closed if the last ≠ first):
RETURN polygon([point({x: 0, y: 0}), point({x: 10, y: 0}),
                point({x: 10, y: 10}), point({x: 0, y: 10})]) AS square

// With holes — a list of rings, ring 0 = exterior, rings 1.. = holes:
RETURN polygon([
  [point({x: 0, y: 0}), point({x: 10, y: 0}), point({x: 10, y: 10}), point({x: 0, y: 10})],
  [point({x: 4, y: 4}), point({x: 6, y: 4}), point({x: 6, y: 6}), point({x: 4, y: 6})]
]) AS ring_with_hole

The CRS is inherited from the points (all points must share one CRS — a mixed-CRS set is an error). A ring needs ≥ 3 distinct vertices. A polygon can be stored as a node/relationship property (it round-trips through storage) but is not spatially indexed (see limitations).

Predicates

// Point-in-polygon (ray casting, correct for holes):
MATCH (p:Place) WHERE within(p.loc, $region) RETURN p
// contains() is the same predicate with arguments swapped:
RETURN polygon.contains($region, point({x: 5, y: 5}))     // → true/false
// Polygon–polygon intersection (overlap or touch):
RETURN intersects($regionA, $regionB)                     // → true/false
  • within(point, polygon) / contains(polygon, point) — even-odd ray-casting point-in-polygon: inside the exterior ring and outside every hole. A point exactly on an edge/vertex is reported inside (a documented boundary convention). Also available as the namespaced polygon.contains(polygon, point) / polygon.within(point, polygon).
  • intersects(polyA, polyB) — a bounding-box reject fast path, then an edge-segment-crossing test, then a vertex-containment test (so containment with no crossing edges still counts). Returns true when the polygons overlap or touch.
  • Mixed-CRS operands → null (parity with distance()).

Limitations (current increments)

  • Geometry is a scoped MVP — a Polygon type with within / contains / intersects only. No linestrings, no multipolygon, no ST_* OGC function library, no spatial joins beyond the point predicates above.
  • No polygon spatial index — polygon predicates are always a full scan + exact filter (a polygon is not added to the tag-0x07 point curve index; a polygon-column BVH / R-tree is an honest follow-on). A “points within a constant query polygon” optimization could later reuse the point bbox-seek.
  • Polygons are 2-D — a polygon’s footprint is [x, y]; a 3-D point’s height is dropped on construction.
  • wgs-84 polygons are treated as planar lon/lat — no antimeridian-crossing and no polar geometry (a polygon spanning the ±180° seam is out of MVP scope). Ray casting / intersection assume a flat plane, which is fine for local regions but not for large geodesic areas.
  • intersects assumes well-formed input — degenerate or self-intersecting polygons are undefined (not asserted).
  • distance() is spherical Haversine, not ellipsoidal (Neo4j’s own default; ~0.3% off the true geoid — fine for radius search, not survey work). Use point.distance.ellipsoidal() when you need the accurate WGS-84 geodesic.
  • 3-D seeks are less selective than 2-D (interleaving three axes has worse curve locality), but still correct — the exact post-filter always runs.

Engrams

Every mutation in patinaDB is recorded. A engram is one committed unit of change — a list of low-level delta operations (create/delete vertex, set property, set/remove label, create/delete edge) plus metadata: an id, a parent id, a timestamp, and an optional message. The chain of engrams is the source of truth for history, diffs, time travel, and — in the server — replication.

Think of it as a git-like commit log for your graph: an append-only history you can inspect, diff, travel through, tag, and compact.

How writes become engrams

Autocommit (the common case). Every write — a REST /cypher call or a Bolt RUN — is captured and committed as one engram, atomically. A single statement, however complex (MATCH … CREATE … SET …, or a bulk UNWIND … CREATE), is one engram.

Explicit transactions. Over Bolt, BEGIN … COMMIT groups several statements into one engram applied atomically at COMMIT (see Bolt). ROLLBACK discards them — no engram is written.

Chunked bulk writes. CALL { … } IN TRANSACTIONS OF n ROWS commits one engram per chunk, so a large load is versioned in bounded steps instead of one giant one — see Procedures → bulk write clause.

Listing history

CALL patinadb.engrams() YIELD id, message, timestamp
RETURN id, message, timestamp ORDER BY timestamp DESC

Snapshots & compaction

To keep time travel fast, patinaDB periodically captures a full-graph snapshot (by default every 50 commits, configurable). Reconstructing a past state loads the nearest snapshot and replays deltas forward from there, rather than replaying the whole history. Snapshots are an internal optimisation — you interact with history through engrams, diffs, tags, and time travel.

Determinism & replication

A engram is a pure, deterministic description of a change: replaying a committed op stream reproduces the exact same graph, and a created vertex’s generated UUID is baked into the op so replays are stable. This is what lets the server replicate — a write becomes a Raft log entry of delta ops, and every node applies the same ops to reach the same state.

The engram lifecycle

History is a managed asset, not just an append-only ledger. These operations let you name, protect, compact, branch, and promote points in it.

Pin — protect an engram from compaction

A pinned engram is never coalesced by squash, so the point-in-time it marks stays reachable.

CALL patinadb.pin('<engram-id>')
CALL patinadb.unpin('<engram-id>')

Tag — a named, snapshotted, pinned point

A tag is a stable name for an engram (like a git tag), so you can refer to a meaningful point without tracking raw ids. Creating a tag pins its engram and takes a full snapshot there, so reading it back is cheap and squash never removes it.

CREATE TAG v1                                  -- tag the current HEAD
CREATE TAG release AS OF '<engram-id>'      -- tag a specific engram
SHOW TAGS                                       -- list name → engram
DROP TAG v1                                      -- remove (unpins if unreferenced)

Read a database as it was at a tag with AS OF TAG. Tags replicate across a cluster — every node names, pins, and snapshots the same engram — so SHOW TAGS and AS OF TAG work against any node.

Squash — compact old history

Coalesce a run of old engrams into a single synthetic genesis, keeping recent history intact. Pinned (and too-recent) engrams are boundaries squash stops at. The live graph is unchanged; only the log is compacted.

CALL patinadb.squash(10)               -- keep the 10 most recent, coalesce older
CALL patinadb.squash(10, 1719792000)   -- …only those older than a unix timestamp

Fork — branch a database at a point

Create a new database seeded with the state of another at a chosen engram (HEAD if omitted). The fork starts with a single genesis engram and its own independent history.

FORK DATABASE prod AS OF '<engram-id>' INTO staging

Restore — promote a past state to HEAD

Bring the state at a past engram back to the live graph as a new engram. History is preserved (nothing is rewritten); the restore is an ordinary append-only write, so it replicates cleanly.

CALL patinadb.restore('<engram-id>')

This is the write-side counterpart to time travel (which is read-only): time travel reads the past, restore promotes it to the present.

Subscribing to changes (CDC)

The engram log is also a live change source: the server’s GET /changes endpoint streams each committed engram as it applies, resumable from an engram cursor — so external systems can react to graph changes as they happen (cache invalidation, search-index sync, ETL).

In the cluster

All of the lifecycle operations are replicated control commands: every node re-derives the result from its own identical history (synthetic genesis ids are content-derived, so the ids agree on every node). SHOW TAGS / SHOW DATABASES are local reads; the mutating commands go through the leader and need admin.

Diffs

patinaDB can show what changed — both for a single engram and between any two points in history.

Single-engram diff

A git-show-style view of one engram: what was created, deleted, and which properties changed (old → new). Repeated SETs on the same property are coalesced, no-op sets are dropped, and prior values are resolved by reconstructing the parent state.

CALL patinadb.diff('<engram-id>')

Range diff (structural)

A structural diff between two reconstructed states — not a replay of the operations between them, but a comparison of the actual graphs at from and to. Omit from (null) to diff against the empty graph.

CALL patinadb.diffRange('<from-id>', '<to-id>')
CALL patinadb.diffRange(null, '<to-id>')

Move pairing

A naive structural diff reports a node that changed identity as one removed and one added node. The range diff is move-aware: it pairs a removed and an added vertex that share a label and match on an identity property, reporting a single move instead of an add/remove pair.

The identity-property priority list defaults to qualified_name, fqn, name.

If two candidates match ambiguously, they are left unpaired (reported as separate add/remove) rather than guessed.

Time Travel

Because every change is recorded as an engram, patinaDB can answer read queries against the graph as it was at any past engram. The state is reconstructed (nearest snapshot + forward delta replay) into a temporary view, and your query runs against that view. The live graph is never modified.

USE … AS OF

Prefix a read with USE <db> AS OF '<engram-id>' to travel back in a specific database:

USE sales AS OF '<engram-id>'
MATCH (o:Order) RETURN count(o)

Over REST you can equivalently pass an at field in the request body; over Bolt the USE … AS OF prefix is parsed per query.

Semantics & constraints

  • Reads only. Time travel reconstructs a read-only past view. You cannot write to the past or “restore” the database to an old state through time travel (that’s a different operation — full snapshot import/export).
  • Consistent point-in-time. A time-travel query sees the entire graph as it was at that engram — a coherent snapshot, not a mix of old and new.
  • Cost. Reconstruction is bounded by the distance from the nearest snapshot to the target engram. Frequent snapshots (see Engrams) keep this cheap; querying a point far from any snapshot replays more deltas. See Performance & tuning below.

An unknown engram id reads as the EMPTY graph — it is not an error. Reconstruction walks the delta chain backwards from the id you name, and an id that isn’t in the log has an empty chain, so the result is a graph with no nodes: count(n) returns 0, every MATCH returns nothing. Your application cannot tell this apart from “the query matched nothing” at that point in time.

This bites in two real ways: a typo’d or stale engram id read by a backend, and — more insidiously — an id that has been squashed away by retention (see Engrams). A squashed engram is genuinely gone, so reading AS OF it silently returns zero rows rather than telling you the history you asked for no longer exists.

If your application time-travels to ids it stores, existence-check first rather than trusting an empty result — CALL patinadb.engrams() lists the reachable timeline. Better still, use a tag — it is pinned against squash, so the point it names stays reachable and SHOW TAGS tells you whether it is still there.

Performance & tuning

An AS OF read is served by one of several paths. All of them return the byte-identical result — the choice affects only speed, and every acceleration falls back to a full reconstruct when it can’t prove it applies. That is why these knobs are safe to flip in either direction on a live system.

  • Full reconstruct (the floor). Materialises the whole graph as of the target engram. Always correct, cost grows with graph size.
  • Warm-window reconstruct (always on). The nearest snapshot’s base state is cached and shared by every read in that window; only the in-window deltas are replayed on top. A second read in the same window is dramatically cheaper than the first.
  • Label-scoped partial reconstruct (PATINADB_PARTIAL_TIMETRAVEL, default on). When a query’s labels and relationship types are statically bounded, only that subgraph is rebuilt instead of the whole graph. An unbounded shape — a bare MATCH (n), a variable-length hop, a procedure call — falls back automatically. Read-only, so a write-only workload pays nothing for it.
  • Point-index lookup (PATINADB_INDEXED_SNAPSHOTS, opt-in). For a cold single-vertex lookup anchored on id(n) = '<uuid>', a per-snapshot .snapidx sidecar turns a full-graph rebuild into a point read.

Practical guidance. Leave PATINADB_PARTIAL_TIMETRAVEL on; if you ever suspect it, =0 restores the full reconstruct for every read — slower, identical results. Reach for PATINADB_INDEXED_SNAPSHOTS only when a known set of historical points is read repeatedly: the sidecar is built lazily and in the background, so the read that triggers a build is itself served by the scan path and only later reads benefit. Bound its disk with PATINADB_MAX_SNAPSHOT_INDEXES (default 16 sidecars). Neither knob touches the write path, so neither can slow a commit or a cluster’s apply loop.

The cheapest historical read of all remains a tag: tagging snapshots its engram, so AS OF TAG never replays a long delta chain however old the point is.

Tags — named, snapshotted points in history

A tag is a stable name for an engram (like a git tag), so you can time-travel to a meaningful point without tracking raw engram ids. Creating a tag also pins and snapshots its engram:

  • Pinned — a tagged engram is protected from squash: compaction never collapses it, so the point-in-time it names stays reachable.
  • Snapshotted — a full snapshot is taken at the tagged engram, so reading AS OF that tag is cheap (no long delta replay), however old it is.
CREATE TAG v1                    -- tag the current HEAD
CREATE TAG release AS OF '<engram-uuid>'   -- tag a specific engram
SHOW TAGS                        -- list name → engram
DROP TAG v1                      -- remove (unpins if no other tag references it)

Read a database as it was at a tag:

USE default AS OF TAG 'v1' MATCH (n) RETURN n

On a cluster, CREATE TAG / DROP TAG replicate through Raft — every node names, pins, and snapshots the same engram — so AS OF TAG and SHOW TAGS work against any node (including followers). SHOW TAGS is a local read; CREATE/DROP TAG need admin and go through the leader.

Anamnesis

Anamnesis is patinaDB’s provenance/lineage system.

patinaDB is versioned by construction — the engram log already records what changed and when. Anamnesis turns that stream into a queryable W3C PROV-style property graph: for every write, who did it (an agent), the commit that did it (an activity), and — at the label / type and property levelwhat kind of change it made and how much.

The projection lives in a separate companion database named <db>__anamnesis, isolated from your main graph so it never touches your graph’s trees or indexes (main-graph read performance is unaffected). Because it is an ordinary database, it is replicated and carried in Raft snapshots for free, and you query it with plain Cypher.

The model

Provenance is aggregated by label/type + property, not one node per touched vertex. A write that creates a million :Ticket rows projects a handful of provenance nodes (one per label and property touched), not a million — the concrete uuids are not duplicated into the PROV graph (see Drilling to concrete uuids).

In <db>__anamnesis, each write projects to:

NodeStable id (uuid5 of)Properties
:Agentnamename
:Activityengram_idengram_id, timestamp, message?
:NodeType(node-label, op)label, op
:EdgeType(edge-type, op)label, op
:Property(on, label, key)label, key, on

where op ∈ {created, updated, deleted} and on ∈ {node, edge}, and the relationships:

  • (:Activity)-[:WAS_ASSOCIATED_WITH]->(:Agent)
  • (:Activity)-[:AFFECTED {count, op}]->(:NodeType | :EdgeType)
  • (:Activity)-[:SET {count}]->(:Property)

Agent, NodeType, EdgeType and Property are upserted by a content-derived stable id, so repeated writes never duplicate them: a (:NodeType {label:'Ticket', op:'created'}) touchpoint is created once and reused by every activity that creates Tickets. Each commit produces exactly one new :Activity, with AFFECTED/SET edges from that activity carrying the per-label counts.

Why op is on the type node. patinaDB edges are keyed by their (outbound, type, inbound) triple with no independent edge id, so two AFFECTED edges from the same Activity to a shared per-label node would collide. The created / updated / deleted touchpoints are therefore distinct :NodeType nodes (one per (label, op)), and MATCH (:NodeType {label:'X'}) returns up to three of them.

How ops map to touchpoints:

  • AFFECTED (nodes): a CREATE(label, created); a DELETE(label, deleted); a property/label set or removal on a pre-existing node → (label, updated). Property/label sets on a node created in the same commit fold into created (creating a node with properties is one act, not a create-then-update).
  • AFFECTED (edges): the same, keyed by the relationship type.
  • SET (properties): every value set — SetVertexProperty / SetEdgeProperty, including on freshly created entities — records a (label, key) :Property touch. This is the property-level tracking axis: “which activity last set Ticket.status?”

The agent is the authenticated RBAC user, or "anonymous" when authentication is disabled. The timestamp is the leader-stamped commit time carried in the Raft entry, so it is identical on every replica.

Opt in

Provenance is off by default (zero cost when off — the write path does no provenance work). Enable it per database:

CALL patinadb.anamnesis.enable()      -- turn it on for the current/USE'd db
CALL patinadb.anamnesis.disable()     -- turn it off (companion data is kept)

USE sales CALL patinadb.anamnesis.enable() targets sales. Enabling creates the sales__anamnesis companion (idempotent) and replicates to every node. Requires the global Admin role.

Querying

The companion is a normal database — the primary path is a plain USE. Query provenance edges in the outbound Activity → target direction:

USE mydb__anamnesis
MATCH (a:Activity)-[r:AFFECTED {op: 'created'}]->(nt:NodeType {label: 'Ticket'})
RETURN a.timestamp AS when, r.count AS how_many
ORDER BY a.timestamp DESC

“Which activities set Ticket.status, and how often?”

USE mydb__anamnesis
MATCH (a:Activity)-[s:SET]->(:Property {label: 'Ticket', key: 'status'}),
      (a)-[:WAS_ASSOCIATED_WITH]->(ag:Agent)
RETURN ag.name AS who, a.timestamp AS when, s.count AS n
ORDER BY a.timestamp DESC

Query direction matters. Reading an edge property (r.count, r.op) across an inbound <- traversal currently returns NULL — a general engine limitation, not specific to provenance. Always traverse out of the :Activity ((a)-[r:AFFECTED]->…) when you need the edge’s count/op.

A convenience read procedure returns the activities that touched a node-label (newest-first):

CALL patinadb.anamnesis('Ticket')
YIELD engram_id, timestamp, message, op, count, agent

(It is server-side sugar for the USE <db>__anamnesis MATCH … query above. The argument is a label, not a vertex uuid — provenance is label-aggregated.)

The main-database engram log also records the writer: CALL patinadb.engrams() YIELD id, author, timestamp shows who committed each engram.

Drilling to concrete uuids

The PROV graph is deliberately coarse: it tells you which labels and properties an activity touched and how many — not which uuids. To get the concrete vertices a commit changed, drill into the engram delta log with the Activity’s engram_id:

CALL patinadb.diff('<engram_id>')   -- the per-vertex added/removed/changed detail

So per-vertex blame (“who last wrote this node”) is a diff-scan over history (cost O(history)), not an O(1) lookup. A reverse uuid → activities index is a possible future add if that access pattern becomes hot.

Enrichment

Auto-projected provenance records the structure of a write (who / what-kind / how-many). Enrichment lets the client attach context to the write — the run/source/model of the activity — so a pipeline can say why and from where it wrote, not just that it wrote. This is activity-level enrichment: each context entry becomes a property on that commit’s :Activity node.

It costs nothing unless you use it, and it is ignored (no error) when provenance is disabled for the target database.

Attaching a context

REST /cypher — add an optional provenance object (an arbitrary key→scalar map) alongside the query:

{
  "query": "CREATE (:Doc {path: 'src/main.rs'})",
  "provenance": {
    "agent":  "indexer-run-42",
    "source": "git://repo@abcd123",
    "model":  "text-embedding-3-large"
  }
}

Bolt — drivers send it as transaction metadata, the canonical Neo4j mechanism. patinaDB reads tx_metadata from the RUN extra (autocommit) and the BEGIN extra (explicit transactions):

# autocommit
session.run("CREATE (:Doc {path: $p})", p="src/main.rs",
            metadata={"agent": "indexer-run-42",
                      "source": "git://repo@abcd123",
                      "model": "text-embedding-3-large"})

# explicit transaction — metadata set once at begin, applies to the whole tx
tx = session.begin_transaction(metadata={"agent": "indexer-run-42",
                                         "source": "git://repo@abcd123"})
tx.run("CREATE (:Doc {path: $p})", p="a")
tx.run("CREATE (:Doc {path: $p})", p="b")
tx.commit()   # one Activity, enriched with the begin metadata

Reserved keys

Some keys are special and are consumed (never folded onto the :Activity):

  • agent (or its alias actor) sets the :Agent name instead of becoming an Activity property — so the recorded agent can be a pipeline or tool (e.g. "indexer-run-42", "nightly-etl") rather than the authenticated RBAC user. When both agent and actor are given, agent wins. Without either, the agent stays the authenticated user (or "anonymous").
  • confidence, source and derived_from are the provenance values. They attach at a client-chosen granularity — see Scope: where the values attach below. By default (scope: changeset) they become :Activity properties, which is cheap. source and derived_from may each be a single scalar or a list — a list is stored as the sorted distinct set of values supplied (a single value stays a plain scalar, unchanged); use this when a write legitimately draws on more than one source.
  • fields is an optional map ({propertyName: confidence}) giving a per-property confidence override, independent of scope — see Per-field confidence below. It is consumed, never emitted as an :Activity property itself.
  • scope selects that granularity (changeset | label | attribute | instance). It is consumed, never emitted.

Every other key becomes an ordinary :Activity property.

Querying enrichment

Enrichment props are ordinary :Activity properties, and the overriding agent is an ordinary :Agent:

USE mydb__anamnesis
MATCH (a:Activity)-[:WAS_ASSOCIATED_WITH]->(ag:Agent)
WHERE a.model = 'text-embedding-3-large'
RETURN ag.name AS agent, a.source AS source, a.timestamp AS when
ORDER BY a.timestamp DESC

Rules and limits

  • Determinism. The context is folded into the projection on the Raft leader and baked into the prov ops carried in the same Raft entry, so every node records a byte-identical enriched Activity.
  • Coercion (best-effort — enrichment never fails the main write). Values are coerced to a stored scalar: strings/numbers/booleans are kept; null values are dropped; nested lists/maps are stringified to JSON. Strings are truncated to 4096 characters, and at most 32 keys are folded (excess dropped in sorted order). Malformed or oversize metadata is clamped, never rejected.
  • Structural keys are protected. engram_id, timestamp and message are owned by the projector; a context key with one of those names is ignored (it cannot overwrite the built-in Activity fields).

Scope: where the values attach

The three provenance values (source / confidence / derived_from) attach at a granularity you choose with the reserved scope key. One write supplies one set of values, applied uniformly to every target at that scope. This lets you record provenance cheaply at the commit level by default, and pay the per-entity cost only when you ask for it.

scopeValues attach to…Cost
changesetthe :Activity (as properties)O(1) — default, cheap
labelthe AFFECTED edgesO(distinct labels touched)
attributethe SET edgesO(distinct properties)
instancea per-vertex GENERATED edge → :EntityO(touched vertices)

scope is case-insensitive; activity is an alias for changeset; an unknown value falls back to changeset (with a warning). If none of source / confidence / derived_from is supplied, scope is irrelevant and nothing extra is emitted — an ordinary write pays only for the label/type touchpoints.

Coercion is best-effort and never fails the write: confidence → float, source / derived_from → string.

source is cheap by default. Because the default scope is changeset, a bare source (or confidence) lands on the single :Activity node — it does not create per-entity nodes. Per-instance cost is paid only when you explicitly ask for scope: instance.

changeset (default) — commit-level

{
  "query": "CREATE (:Ticket {status: 'open'})",
  "provenance": { "source": "git://repo@abcd", "confidence": 0.9 }
}

source and confidence become :Activity properties (scope omitted ⇒ changeset):

USE mydb__anamnesis
MATCH (a:Activity)-[:WAS_ASSOCIATED_WITH]->(ag:Agent)
WHERE a.source = 'git://repo@abcd'
RETURN ag.name AS who, a.confidence AS confidence, a.timestamp AS when

label — per touched node/edge type

{
  "query": "MATCH (t:Ticket) SET t.status = 'closed'",
  "provenance": { "scope": "label", "source": "bulk-migration-7" }
}

The value rides every AFFECTED edge of the write:

USE mydb__anamnesis
MATCH (a:Activity)-[r:AFFECTED]->(nt:NodeType {label: 'Ticket'})
RETURN nt.op AS op, r.count AS n, r.source AS source

attribute — per touched (label, key)

{
  "query": "MATCH (t:Ticket) SET t.priority = 3",
  "provenance": { "scope": "attribute", "source": "triage-rules-v2", "confidence": 0.8 }
}

The value rides every SET edge:

USE mydb__anamnesis
MATCH (a:Activity)-[s:SET]->(p:Property {label: 'Ticket', key: 'priority'})
RETURN s.count AS n, s.source AS source, s.confidence AS confidence

instance — per changed entity (the expensive opt-in)

This is the shape an extraction / LLM pipeline needs when it asserts a fact per write: “for this specific entity, which run generated it, with what confidence, from what source?”

{
  "query": "CREATE (t:Ticket {summary: 'db is slow'})",
  "provenance": { "scope": "instance", "agent": "run-42", "source": "doc://y", "confidence": 0.9 }
}

For each vertex created or updated in the write, in <db>__anamnesis:

  • (:Entity { ref_uuid, ref_label }) — one per concrete main-graph entity, keyed by a uuid5 of its ref_uuid (idempotent upsert: repeated writes to the same entity reuse the node). ref_label is the entity’s primary label.
  • (:Activity)-[:GENERATED { confidence?, source?, derived_from? }]->(:Entity) — one per (activity, entity), carrying the reserved values as edge properties.

The reused :Agent / :Activity are the same Layer-1 nodes (instance scope never duplicates them). Deleted vertices are skipped — per-instance provenance of a now-deleted entity is out of scope.

Per-entity provenance — “which run generated this ticket, with what confidence?” (read the edge props traversing out of the :Activity, or across an inbound <- — the latter is supported):

USE mydb__anamnesis
MATCH (e:Entity {ref_uuid: '…'})<-[g:GENERATED]-(a:Activity)-[:WAS_ASSOCIATED_WITH]->(ag:Agent)
RETURN ag.name AS run, g.confidence AS confidence, g.source AS source

All low-confidence entities:

USE mydb__anamnesis
MATCH (a:Activity)-[g:GENERATED]->(e:Entity)
WHERE g.confidence < 0.5
RETURN e.ref_label AS label, e.ref_uuid AS uuid, g.confidence AS confidence
ORDER BY confidence ASC

Determinism

At every scope the values are folded into the projection once on the Raft leader and baked into the prov ops carried in the same Raft entry, so the result — Activity props, edge decorations, or the per-instance graph — is byte-identical on every node.

Per-field confidence (the fields map)

A write’s confidence/source/derived_from are otherwise applied uniformly across every target at the chosen scope — but a single write often touches several properties with genuinely different confidence (e.g. one field copied verbatim, another inferred). The reserved fields map lets you override confidence per property, independent of scope:

{
  "query": "MATCH (t:Ticket) SET t.summary = 'db is slow', t.category = 'perf'",
  "provenance": {
    "source": "doc://y",
    "fields": { "summary": 0.95, "category": 0.4 }
  }
}

Each named property’s SET edge carries its own confidence (0.95 for summary, 0.4 for category), overriding whatever a scope: attribute confidence would otherwise have set — fields always wins for the properties it names. Properties not listed in fields still get the write’s ordinary scope-level confidence, if any.

Multiple sources, one write

source and derived_from accept a list as well as a scalar. The target (the :Activity, the AFFECTED/SET edge, or the GENERATED edge — whichever the chosen scope attaches to) then carries the sorted, distinct set of values supplied, instead of the last one silently overwriting the rest:

{
  "query": "MATCH (t:Ticket) SET t.priority = 3",
  "provenance": { "scope": "attribute", "source": ["triage-rules-v2", "manual-override"] }
}

A single value still stores as a plain scalar (byte-identical to before this existed) — the list form is opt-in.

One value (or value-set) per scope, per write

A write supplies one set of values (each possibly multi-valued, via fields/list-form above), applied to all targets at the chosen scope. A fully independent value per individual target — e.g. one arbitrary map keyed by entity id under scope: instance, rather than the same value(s) applied to every entity in the write — is a possible future extension and is not built. Field-level provenance (the fields map) is per (label, key) across the whole write, not per (entity, key).

Honest caveats

  • Write amplification is now O(labels + properties), not O(nodes). A write projects a handful of touchpoint upserts + per-label count edges regardless of how many rows it touched. It is still non-zero work per write — keep provenance off during bulk loads if you don’t need it, and enable it afterward (the companion only reflects writes made while it was enabled).
  • Per-uuid blame is a diff-scan, not a graph lookup — see Drilling to concrete uuids.
  • Server path only. Provenance is projected on the Raft server write path (REST /cypher and Bolt) — it is computed once on the leader and replicates verbatim alongside the graph write.
  • Unresolvable labels bucket under "?". A vertex property/delete op carries only a uuid; the leader resolves its label from the live graph. In the should-never-happen case that a label can’t be resolved, the touch is bucketed under label "?" (and logged) rather than dropped.
  • Activity ↔ engram correlation is best-effort. Activity.engram_id is the main engram id predicted at propose time; under concurrent-write interleaving it could differ from the recorded id. The provenance graph itself is always internally consistent and byte-identical across replicas.

The Server (Raft)

patinadb-raft is the patinaDB server: a standalone node that holds one or more databases, replicates writes with the Raft consensus protocol (openraft 0.9), and exposes both a JSON REST API and the native Bolt protocol. It scales from a single self-leading node (a plain server with no consensus latency) up to a highly-available multi-node cluster with automatic failover.

Running a node

patinadb-raft \
  --id 1 \
  --addr 127.0.0.1:21001 \
  --db ./data \
  --bootstrap \
  --bolt-addr 127.0.0.1:7687 \
  --auth-user neo4j \
  --auth-password secret
FlagDefaultMeaning
--id <u64>(required)Unique Raft node id within the cluster.
--addr <host:port>(required)HTTP listen address (REST + peer RPCs + management).
--db <dir>(required)Database root directory — one subdirectory per database.
--bootstrapoffInitialize a single-voter cluster so this node leads itself immediately.
--bolt-addr <addr>127.0.0.1:7687Bolt listener. "" disables Bolt.
--advertised-addr= --bolt-addrBolt address advertised in routing / SHOW DATABASES (set behind proxy).
--auth-user <name>neo4jUsername for REST Basic / Bolt LOGON / peer RPCs.
--auth-password <p>"" (open!)Shared password. Empty = authentication disabled. Also PATINADB_AUTH_PASSWORD.

--bootstrap is the easy button. One node with --bootstrap is a complete, working server — it elects itself leader and accepts writes with no further setup. You only need the management endpoints when growing to multiple nodes.

What a node exposes

  • REST on --addr — see REST API.
  • Bolt on --bolt-addr — see Bolt & Neo4j Browser.
  • Management & peer RPC on --addr/mgmt/init, /mgmt/add-learner, /mgmt/change-membership, /mgmt/metrics, /health, /ready, /version, and the internal /raft/* receivers.

The replication model

Every write is turned into a deterministic batch of delta operations and committed through Raft as one log entry. Every node applies the committed entry to its own local graph — there is no leader/follower divergence and no separate read-replica path for data: a read on any node serves that node’s applied state. DDL control commands (CREATE/DROP DATABASE, CREATE/DROP FULLTEXT INDEX) replicate the same way.

The leader resolves a Cypher write against an ephemeral mirror of HEAD to capture the concrete ops without mutating anything locally, then proposes Write { db, ops }; the actual mutation happens uniformly when the entry commits and every node applies it.

Durability

Both the Raft log and the state machine are backed by the embedded B-tree store and persistent: the log survives restart, and last_applied is persisted so a restart resumes without re-applying the whole log. The graph itself is already on disk. A node can be killed and restarted and it rejoins with its state intact.

Continue with REST API, Bolt & Neo4j Browser, Multi-Database, High Availability, and Authentication & TLS.

REST API

The server exposes a small JSON-over-HTTP API on its --addr. Every route except /health and /version requires HTTP Basic auth when a password is set (see Authentication & TLS).

POST /cypher (alias POST /query)

Run a Cypher query. Request body:

{
  "query": "MATCH (n:Person) RETURN n.name AS name LIMIT 10",
  "db": "default",
  "at": null,
  "consistency": "local"
}
FieldRequiredMeaning
queryyesThe Cypher (or DDL) statement.
dbnoTarget database (default "default"). Overridden by USE.
atnoEngram id for a time-travel read (equivalent to USE … AS OF).
consistencyno"local" (default) or "linearizable". See below.

consistency controls read freshness (ignored for writes):

  • "local" (default) — serve this node’s applied state. Fast, causally consistent within a database, eventually consistent across replicas.
  • "linearizable" — reflect every write committed before the read began, via a leader read-index barrier. Leader-only: a follower returns 503 with a leader_id / leader_addr hint (like a misrouted write). Costs one intra-cluster round-trip. See High Availability.
curl -s -u neo4j:secret -X POST http://127.0.0.1:21001/cypher \
  -H 'content-type: application/json' \
  -d '{"query":"CREATE (n:Person {name:\"Ada\"}) RETURN n"}'

Statement routing

The handler dispatches a statement in this order:

  1. Full-text DDL (CREATE/DROP FULLTEXT INDEX, SHOW FULLTEXT INDEXES) — schema commands replicate through Raft; SHOW reads the local catalog and returns {"fulltextIndexes": [...]}.
  2. Database DDL (CREATE/DROP DATABASE → replicated; SHOW DATABASES → local registry read).
  3. A leading USE <db> selector (optionally USE <db> AS OF '<id>') picks the target database / time-travel point.
  4. Otherwise: writes go through Raft and apply on every node; reads serve from the local applied graph.

The response is JSON with the result rows (column order and per-row alignment are preserved). Errors come back as a JSON error with an appropriate HTTP status.

GET /health

Liveness check. No auth. Returns OK whenever the node’s HTTP server is up — even if the node isn’t ready to serve (see /ready, below).

GET /ready

Readiness check. No auth, never shed by the concurrency limiter. Returns 200 only when the node has a known leader, isn’t installing a snapshot, isn’t community-degraded, isn’t lagging, and isn’t shedding load — otherwise 503 with a machine-readable reason. Point a load balancer / Kubernetes readiness probe / neo4j:// read rotation at this, and /health at the liveness probe. See Day-2 Operations → Health vs. readiness probes.

GET /version

Server version string, protocol version, and (for a licensed/entitled node) resolved tier + usage. No auth.

Management endpoints

On --addr, for cluster operations (see High Availability). This table covers the core cluster/backup surface; several other /mgmt/* routes are documented alongside the feature they belong to rather than repeated here: /mgmt/evict-voter (Day-2 Operations), /mgmt/audit, /mgmt/rotate-cluster-secret, /mgmt/rotate-admin-password (Authentication & TLS), /mgmt/transactions (SHOW/TERMINATE TRANSACTIONS), and /mgmt/upsert / /mgmt/upsert-edges / /mgmt/upsert-graph (Bulk Upsert). Every /mgmt/* route needs admin credentials.

EndpointPurpose
POST /mgmt/initInitialize a cluster (alternative to --bootstrap).
POST /mgmt/add-learnerAdd a node as a non-voting learner.
POST /mgmt/change-membershipPromote learners to voters / change the voter set.
GET /mgmt/clusterFull voter/learner topology + addresses + current leader.
GET /mgmt/metricsRaft metrics (leader, term, membership, lag).
GET /mgmt/queriesPer-query-shape stats (count, mean, p95, max latency) — this node’s local view.
GET /mgmt/dbsizesPer-database on-disk size, largest first.
GET /mgmt/cacheMulti-level cache governor state: budget + per-level/-scope bytes & hit rates (see below).
GET /mgmt/snapshotStream a portable, leader-anchored backup of every database (see below).
GET /mgmt/exportStream one database as an import-compatible CSV tar (see below).
POST /mgmt/restoreRestore a backup into the registry (leader-only, admin; see below).

/raft/append, /raft/vote, /raft/snapshot are the internal peer RPC receivers — not for client use.

GET /mgmt/cache (cache observability)

Reports the node-local, RAM-budget-governed cache hierarchy. Admin-only (it is under /mgmt/). The JSON carries the resolved budget (bytes per region: total, cache_limit, work_mem_limit, headroom, min_free), the overall utilization, a governor block (admission + the free-floor-vs-cap eviction-byte split), the per-levels resident bytes / hit rates with per-scope accounting (which database, collection (label), or query shape is hot), a sankey read-flow block, and the governor’s last free-RAM sample (mem_available_bytes, read from /proc/meminfo). While caching is disabled (the default) it is a truthful all-zeros “just the OS page cache” report:

{
  "enabled": false,
  "budget": { "total": 0, "cache_limit": 0, "work_mem_limit": 0, "headroom": 0, "min_free": 0 },
  "total_resident_bytes": 0,
  "utilization": 0.0,
  "governor": { "admissions": 0, "rejections": 0, "evicted_free_floor_bytes": 0, "evicted_cap_bytes": 0 },
  "levels": [],
  "sankey": { "total_lookups": 0, "misses": 0, "layers": [ { "name": "miss", "label": "miss → storage", "value": 0 } ] },
  "mem_available_bytes": 12884901888
}

An enabled node fills levels (one entry per cache layer, each with bytes/entries/hit_rate/utilization/avg_entry_bytes/evicted_entries/ gen_invalidations/scope_invalidations + hottest-first per-scope scopes rows) and the sankey layers with per-layer absorbed hits.

The same accounting is available in-query as CALL patinadb.cache.stats() YIELD scope, kind, bytes, entries, hit_rate, generation, and as Prometheus gauges on /metrics (patinadb_cache_bytes{level,db}, patinadb_cache_hit_ratio{level}, patinadb_cache_evictions_total, patinadb_mem_available_bytes, …). For the full metric reference and a tuning playbook see Cache Observability & Tuning.

GET /mgmt/snapshot (backup / export)

Streams a portable backup of the whole registry — every database’s graph plus its schema and access-control surface — as a single downloadable JSON document (Content-Type: application/json, Content-Disposition: attachment; filename="patinadb-backup.json").

Query parameters:

ParamDefaultMeaning
historyfalseWhen true, additionally stream each database’s full engram history (delta + snapshot bodies) for point-in-time restore (PITR). See “Point-in-time restore” below.

The backup is built and streamed in O(chunk) memory, not O(graph): each database’s graph is serialized lazily straight from its scan iterators to a temp file (the same streaming serializer the Raft snapshot build uses), and that file is streamed back as the response body and deleted once sent. The payload carries no Raft metadata (no log id / membership), so it is a portable data dump — readable and restorable into any fresh registry:

{
  "databases":         { "<db>": <graph snapshot> },   // graph (HEAD state)
  "fulltext":          { "<db>": [<index def>] },       // full-text indexes
  "vector":            { "<db>": [<index def>] },       // vector indexes (trained centroids)
  "constraints":       { "<db>": [<unique constraint>] },
  "schema_constraints":{ "<db>": [<existence / node-key constraint>] },
  "tags":              { "<db>": [["<name>", "<engram uuid>"]] },  // tag names
  "users":             [<rbac user>],                   // global: hash + per-db roles
  "engram_history":    { "<db>": <full engram history> }, // ONLY with ?history=true
  "heads":             { "<db>": "<engram uuid>" },     // informational
  "taken_at_unix_ms":  <millis>                          // informational
}

So a /mgmt/snapshot/mgmt/restore round-trip reconstitutes each database including its UNIQUE / existence / node-key constraints, compound / vector / full-text indexes, named tags, and the RBAC user directory (argon2 hashes + per-database role grants) — not just the graph. Every schema/RBAC field is optional, so an older backup (from before these were carried) still restores. (restore ignores the heads / taken_at_unix_ms fields — informational labels.)

Point-in-time restore (PITR) — ?history=true

By default (?history=true omitted) the backup is a compact HEAD-state dump: it captures each database’s current graph plus its schema and RBAC, but not its engram delta/snapshot history. After restoring a HEAD-only backup the graph, constraints, indexes, users, and tag names are all back, but time-travel over pre-backup history is gone — AS OF <old-engram-id> / CALL patinadb.diff on engrams that predate the backup are unavailable, and every restored tag resolves to the restored-HEAD engram.

Add ?history=true to make it a PITR archive: the backup additionally streams each database’s full engram history (engram_history above — the per-engram delta bodies and periodic snapshot bodies). A /mgmt/restore of such a backup reconstitutes the whole timeline, so on the restored node:

  • USE <db> AS OF '<old-engram-id>' reconstructs that historical state (not HEAD),
  • USE <db> AS OF TAG <name> resolves to the tagged engram’s point in history,
  • CALL patinadb.diff('<engram-id>') returns that engram’s historical delta,
  • and HEAD is the live restored graph, as always.

The history is streamed body-by-body (one delta / snapshot at a time), so a PITR backup keeps peak memory bounded rather than materialising the whole timeline. The engram_history field is omitted entirely from a HEAD-only backup, and it decodes as empty on restore (#[serde(default)]), so an old backup — or a new HEAD-only one — still restores unchanged.

Cluster caveat. /mgmt/restore installs the engram history on the node that receives the request (the leader). The HEAD graph replicates to followers via Raft, but the delta/snapshot bodies do not — so PITR is a leader-local capability. A follower that later bootstraps purely from a Raft snapshot carries only the metadata summary (the pre-existing engram-log limitation). For a single-node restore (the documented use), full PITR works everywhere.

Leader-anchored, point-in-time labelled

The export takes a leader linearizability barrier (ensure_linearizable) before it reads anything, so:

  • It is leader-only. A follower (or any non-leader) refuses the backup with a 503 naming the current leader ({ "leader_id", "leader_addr", … }), the same contract as a misrouted write or a linearizable read — you can never accidentally take a stale backup from a lagging replica.
  • It reflects every write committed before it began, and records as of what point each database was captured: under the barrier it captures each database’s HEAD engram id (heads[<db>], matching Dataset::head on the leader) plus a wall-clock taken_at_unix_ms.

Consistency caveat (be precise). The per-database graphs are still serialized from their live state, not reconstructed as-of their pinned HEAD. A full as-of reconstruction would have to materialise each graph in memory (O(graph)), which would break the O(chunk) streaming property, so it is intentionally not done here. The practical guarantee is therefore:

  • No stale-follower backups (leader barrier), and each database is labelled with the exact HEAD it was captured at.
  • Cross-database point-in-time consistency holds when writes are quiesced during the export. If writes continue while the (multi-database) backup streams, a write that lands after the barrier can still be included in a database that is serialized later — the recorded per-db heads tell you the intended cut, but the live bytes may run slightly ahead of it. For a guaranteed cross-database snapshot, take the backup from a quiescent cluster.

Auth-protected like every other management route (it is a full data dump): when a password is set, valid HTTP Basic credentials are required.

curl -s -u neo4j:secret http://127.0.0.1:21001/mgmt/snapshot \
  -o patinadb-backup.json

GET /mgmt/export (portable CSV export)

Streams one database’s graph as a portable, import-compatible dump — neo4j-admin-style node/relationship files a LOAD CSV load or a bulk-upsert call can read back.

GET /mgmt/export?db=<name>&format=csv
  • db — which database to export (default default).
  • formatcsv, parquet, or arrow.

The response is a tar archive (Content-Type: application/x-tar, Content-Disposition: attachment; filename="patinadb-export-<db>.tar") containing, in the exact neo4j-admin convention the importer reads:

  • nodes_<Label>.csv — a :ID(uuid) column, one typed column per property (age:int, score:float, …), and a trailing :LABEL column.
  • rels_<TYPE>.csv:START_ID(uuid), :END_ID(uuid), :TYPE, and one typed column per edge property.
  • _schema.json — a sidecar carrying the database’s UNIQUE / existence / node-key constraint defs plus compound / full-text / vector index defs, so an exported database can be fully reconstituted. (/mgmt/snapshot now also carries these — see above — so both paths preserve the schema; /mgmt/export is single-database + CSV, /mgmt/snapshot is whole-registry + JSON and additionally carries RBAC users and tags.)

Because the id column is the raw vertex UUID under an :ID(uuid) header, a re-import reproduces the same graph with byte-identical UUIDs (the importer uses the cell literally instead of hashing it). Round-trip:

curl -s -u neo4j:secret \
  "http://127.0.0.1:21001/mgmt/export?db=default&format=csv" -o export.tar
mkdir out && tar xf export.tar -C out

Reload it with LOAD CSV against the extracted files (see Bulk Loading & Import), or drive it through POST /mgmt/upsert/upsert-edges (see Bulk Upsert) if you want match-or-create semantics on the reload.

Like /mgmt/snapshot, it takes a leader linearizability barrier (so it is leader-only and reflects every committed write; a follower answers 503 with a leader hint) and streams in O(chunk) memory (per-label CSVs are written row-by-row to a temp dir, then tar’d to a temp file that is streamed back and deleted). Auth-protected — it is a /mgmt/ route, so it needs admin credentials.

POST /mgmt/restore (restore / import)

Restores a backup produced by GET /mgmt/snapshot into the registry. Send it to the leader (every step is a replicated write, so a follower answers with a 503 leader hint) and it needs admin credentials (it’s a /mgmt/ route).

curl -s -u neo4j:secret -X POST http://127.0.0.1:21001/mgmt/restore \
  -H 'content-type: application/json' \
  --data-binary @patinadb-backup.json
# → {"databases": 2, "ops_applied": 12345, "users_restored": 3}

For each database in the payload it issues CREATE DATABASE (idempotent), then streams the graph back as chunked replicated writes (so the restore commits through Raft and appears on every node), then re-registers, in order: the full-text index defs, the compound-index defs, the vector-index defs (with their trained centroids), the UNIQUE + existence/node-key constraints (rendered back to CREATE CONSTRAINT … IF NOT EXISTS DDL and replayed via the same replicated path interactive constraint DDL uses), and the tag names (re-created pointing at the restored HEAD). Finally it restores the RBAC user directory — each user (argon2 hash replayed verbatim, so the original password still works) plus its per-database role grants. Every step is a replicated, idempotent write, so a multi-node cluster converges and re-running the same backup is a no-op — ideal for loading a backup into a fresh (empty) cluster.

Non-empty-target guard. Before touching anything, the server checks each database named in the payload: if a database of that name already exists and is non-empty, the restore is refused outright (409 Conflict, naming the non-empty database(s)) — nothing is written. This exists precisely because the restore is additive, not a wipe-then-replace: pointing it at the wrong (already populated) target would otherwise silently merge/duplicate data with no warning. Pass ?force=true to proceed anyway and restore over the existing data. An absent or already-empty target database restores with no flag needed — the common fresh-restore case is unaffected.

curl -s -u neo4j:secret -X POST 'http://127.0.0.1:21001/mgmt/restore?force=true' \
  -H 'content-type: application/json' --data-binary @patinadb-backup.json

What restore does NOT bring back. The backup is a HEAD-state dump, not a PITR archive (see /mgmt/snapshot above): engram history is not carried, so after a restore, time-travel over pre-backup engrams and pre-backup diffs are gone, and each restored tag resolves to the single restored-HEAD engram (not its original point in history). The anamnesis-enabled flag is not re-toggled on restore (the <db>__anamnesis companion database itself rides the backup as ordinary data, but automatic projection must be re-enabled with CALL patinadb.anamnesis.enable()). The backup is leader-anchored and HEAD-labelled, but each database’s graph is serialized from live state rather than reconstructed as-of its pinned HEAD, so cross-database point-in-time consistency only holds when writes are quiesced during the export.

Bolt & Neo4j Browser

The server speaks the native Bolt protocol, so the official Neo4j drivers and the Neo4j Browser connect to patinaDB directly. This has been validated end-to-end against Neo4j driver 6.2.0 (over both raw TCP and WebSocket) and the Neo4j Browser (node creation plus path/relationship graph visualisation).

Connecting

The Bolt listener (default 127.0.0.1:7687) auto-detects the transport: a raw Bolt TCP handshake or a WebSocket upgrade (the Browser’s JavaScript driver speaks Bolt inside WebSocket binary frames). Both work on the same port.

Neo4j Browser: open browser.neo4j.io, connect to bolt://localhost:7687, authenticate with --auth-user / --auth-password.

Official driver (Python):

from neo4j import GraphDatabase
drv = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "secret"))
with drv.session() as s:
    s.run("CREATE (a:Person {name:$n})", n="Ada")
    for rec in s.run("MATCH (n:Person) RETURN n"):
        print(rec["n"])
drv.close()

Nodes, relationships, and paths come back as proper Bolt graph types (Node / Relationship / Path), so drivers hydrate them as graph objects and the Browser visualises them.

Protocol version negotiation: the server negotiates whatever Bolt version a client offers during the handshake, including modern 5.x. A client that only speaks an older version still works, with reduced fidelity where that version’s wire format is narrower — for example, a client negotiating Bolt 4.4 (some non-official/community drivers, e.g. Rust’s neo4rs 0.9, only offer 4.4) does not receive the newer element_id field on nodes/relationships; use id(n) in Cypher instead if you need a stable per-node identifier over such a client.

Spatial values over Bolt

A patinaDB Point is sent as a native Neo4j Point struct, so official drivers decode it as a first-class point (neo4j.spatial.Point / a driver’s Point type):

  • a 2-D point → PackStream struct tag 0x58 with fields {srid: Integer, x: Float, y: Float};
  • a 3-D point → tag 0x59 with an added z: Float.

The srid is the CRS discriminant patinaDB already stores — cartesian 7203, cartesian-3d 9157, wgs-84 4326, wgs-84-3d 4979 (the same SRIDs Neo4j uses). For wgs-84 the point’s x is the longitude and y the latitude.

A point can also be passed as a parameter: send $center as a 0x58/0x59 struct and it decodes back into a point you can use in Cypher, e.g. RETURN distance($center, n.loc).

Polygons have no native Bolt type. Bolt only defines Point2D/Point3D, so a patinaDB Polygon is returned as a Map{type: "Polygon", srid: Integer, rings: [[[x, y], …], …]} (rings[0] is the exterior ring, rings[1..] the holes). A driver therefore sees a plain map, not a spatial object; decode it yourself if you need the geometry.

Authentication

Auth is checked at LOGON (scheme: "basic"). An unauthenticated connection cannot run queries. See Authentication & TLS.

Streaming reads

Read results stream lazily: the query runs on a worker thread that pushes hydrated records over a bounded channel, with PULL n batching and backpressure. Memory stays bounded regardless of result size, so you can stream large result sets without materialising them all server-side.

Transactions

Both autocommit and explicit transactions are real.

  • Autocommit (a bare RUN): each statement commits on its own as one engram — atomic, even for a multi-clause or bulk statement.
  • Explicit (BEGIN … COMMIT / ROLLBACK): statements between BEGIN and COMMIT are buffered, not committed one by one. BEGIN pins a consistent snapshot of the database; every RUN sees that snapshot plus the transaction’s own uncommitted writes (repeatable reads + read-your-own-writes); COMMIT applies the whole buffer as one atomic engram (a single Raft entry on a cluster) after a conflict check; ROLLBACK discards it — nothing is written.

Explicit transactions run at snapshot isolation (SI). A transaction is bound to one database — a mid-transaction USE <other> is rejected — and schema/admin statements (e.g. CREATE INDEX, CREATE DATABASE) run eagerly, not buffered. The official Neo4j drivers’ managed transaction functions work as expected, including automatic retry on the conflict error below.

Isolation: explicit transactions are snapshot-isolated, not serializable

  • BEGIN pins the database HEAD (the serialization point). Every read in the transaction observes the graph as of that moment — a commit by another connection made after your BEGIN is invisible to your transaction (repeatable reads, no phantoms). Your own buffered writes are visible to your own reads (read-your-own-writes).
  • COMMIT is first-committer-wins. Before proposing the buffer, the server checks whether any write committed since your pinned snapshot touched an entity/property your transaction also wrote. If so, the COMMIT is rejected with a transient error (Neo.TransientError.Transaction.LockClientStopped), which the Neo4j drivers’ managed-transaction functions retry automatically. This prevents lost updates (e.g. two clients that both “read a counter, increment, write it back” — one commits, the other retries against the new value instead of silently clobbering it).
  • Autocommit (a bare single-statement RUN) holds the per-database write lock for the statement, so autocommit writes to one database are serialized.

What SI does not give you: write skew

Snapshot isolation detects write-write conflicts only. A read-write conflict with disjoint write-sets (write skew) is still possible: two transactions can each read a value the other overwrites, as long as they write different things, and both commit. This is the standard, well-understood SI limitation — it is not serializable and not linearizable. If you need a constraint that spans rows one transaction reads and another writes (e.g. “at most one on-call engineer”), enforce it in a single autocommit statement or with an application-level guard. See Limitations.

Conflict granularity

Property writes conflict at (entity, property) granularity (two transactions updating different properties of the same node do not falsely conflict); create/delete and label changes conflict at whole-entity granularity. Freshly-created nodes carry new UUIDs, so concurrent inserts never conflict.

Causal consistency (bookmarks)

A standard Neo4j driver session gets read-your-writes automatically: the driver threads an opaque bookmark from each statement’s result into the next session.run call, and the server blocks that next read until it has caught up to the bookmark. This now actually works end-to-end against patinaDB — no client code change needed, since it’s the driver’s default session behaviour.

Concretely:

  • Every successful autocommit write (a bare RUN) and every successful COMMIT returns a bookmark encoding the Raft log index that write was committed at.
  • A subsequent BEGIN or autocommit RUN carrying that bookmark blocks until this connection’s node has applied that index, before doing anything else — for BEGIN, before it pins its snapshot-isolation snapshot, so the pinned snapshot itself already reflects the bookmarked write.
  • Once caught up (the common case, especially against the leader — no wait at all), the statement runs as normal.

This matters most when a routing (neo4j://) driver load-balances a session’s reads across followers (see cluster-aware routing below): without a bookmark, a write-then-read session could land its read on a follower that hasn’t replicated the write yet and get a stale result with no error. With bookmarks, that same session either waits briefly for the follower to catch up, or times out loudly — it never silently returns stale data.

The wait is bounded: --bookmark-wait-timeout-secs (default 10 seconds; see Configuration Reference) caps how long a node will wait for its own applied state to catch up to an incoming bookmark. If that bookmark can’t be reached in time, the client gets a driver-retryable Neo.TransientError.Transaction.BookmarkTimeout — the driver retries, and its routing may pick a more caught-up member — never a silent stale read. Set --bookmark-wait-timeout-secs 0 to disable the wait entirely (bookmarks are then parsed but ignored, reverting to plain possibly-stale-follower reads).

Read-consistency by surface

Different ways of talking to patinaDB give different freshness guarantees for a read that follows a write:

SurfaceGuarantee
Bolt driver session (bookmarks)Read-your-writes — automatic, bounded-wait (above). No client change needed.
REST POST /cypher, "consistency":"local" (default)Eventually consistent on a follower; fast, no wait.
REST POST /cypher, "consistency":"linearizable"Read-your-latest-write against live HEAD — leader-only, costs a round-trip. See REST API.
A plain follower read with no bookmark / no linearizable flagEventually consistent — bounded staleness. The neo4j:// routing table already excludes chronically-lagging followers (see High Availability).

See High Availability → Read consistency for the full discussion, including the lag-immune AS OF TAG pattern for reads that must be reproducible across followers with no leader round-trip at all.

Connection resource limits (unauthenticated slowloris hardening)

Three coordinated guards close an unauthenticated connection-exhaustion attack (issue #440): an attacker who opens Bolt sockets and either sends nothing, stalls partway through the version-negotiation handshake, or completes HELLO/LOGON and then idles, used to hold a --max-bolt-connections permit indefinitely — a few thousand such connections was a complete driver-protocol lockout, not merely file-descriptor pressure.

  • --bolt-handshake-timeout-secs (PATINADB_BOLT_HANDSHAKE_TIMEOUT_SECS, default 10): bounds every pre-authentication step an accepted socket can stall on — the TLS ClientHello (--tls-cert), the raw-Bolt-vs-WebSocket dispatch first-byte peek, the WebSocket HTTP upgrade (the Neo4j Browser transport), and the 20-byte Bolt version-negotiation handshake. So on all three transports (plain TCP, TLS, WebSocket) a socket that sends nothing at all, stalls partway through the TLS/WebSocket handshake, or sends fewer than 20 Bolt handshake bytes and then stalls, is closed cleanly once this deadline elapses instead of pinning the connection task forever. Set 0 to disable.
  • --bolt-idle-timeout-secs (PATINADB_BOLT_IDLE_TIMEOUT_SECS, default 300): bounds how long a connection may sit with no open explicit transaction and no started-but-unconsumed result stream — those two states have their own dedicated reapers, --idle-tx-timeout-secs and --stream-pull-timeout-secs below — while waiting for the next client message. Covers a connection that finishes the handshake and/or HELLO/LOGON and then goes silent. The clock resets on every client message, so a well-behaved driver never trips it. Set 0 to disable.
  • The --max-bolt-connections permit is acquired only after a successful LOGON (or, with --insecure-disable-auth, on a connection’s first message — auth-disabled connections have no separate LOGON event to key off of), never at TCP accept time. An unauthenticated connection — silent, stalled mid-handshake, or idling after HELLO — therefore never consumes the connection budget meant for real, authenticated clients; it is bounded purely in time by the two deadlines above. A connection that is over the cap at the moment it would authenticate gets a Neo.TransientError.General.DatabaseUnavailable failure (driver-retryable) in place of its normal authentication success, and the socket is closed.

Transaction resource limits

Two operational guards bound an explicit transaction so a runaway or abandoned one can’t exhaust server resources (both default ON, opt out with 0):

  • --max-tx-ops (PATINADB_MAX_TX_OPS, default 5000000): the maximum number of resolved write operations an explicit transaction may buffer across all its statements before COMMIT. The buffer commits as one Raft entry, so this bounds connection RAM and the entry size. Exceeding it aborts and rolls back the transaction with a driver-retryable transient error — split a very large load into smaller transactions (or use CALL { … } IN TRANSACTIONS, which commits in bounded chunks). Each individual statement is separately bounded by PATINADB_MAX_CAPTURE_OPS.
  • --idle-tx-timeout-secs (PATINADB_IDLE_TX_TIMEOUT_SECS, default 300): the “idle in transaction” guard. An explicit transaction that has been open with no client activity for this long is rolled back and its connection is closed by a background reaper — so an abandoned BEGIN (a client that opened a transaction and then went away without COMMIT/ROLLBACK) can’t hold resources forever. The clock resets on every statement, so a slow-but-active transaction is never reaped. Set 0 to disable the reaper.

Database selection

The target database is taken per-RUN / per-BEGIN (from the Bolt db field or a USE <db> prefix) and is never sticky across autocommit statements — connections are pooled, so each statement resolves its own database. See Multi-Database.

System / introspection shim

On connect, the Neo4j Browser fires admin/introspection statements that are not ordinary Cypher (CALL dbms.components(), SHOW DATABASES, CALL db.labels(), routing-table lookups, …). The server recognises these and returns canned/registry-derived results so the Browser connects cleanly, shows a server version, and populates its database dropdown. Real procedures (notably CALL db.index.fulltext.*) are not swallowed by the shim — they reach the engine and return real data.

Cluster-aware routing

neo4j:// (routing) drivers ask the server for a routing table and then connect to the addresses it returns. In a cluster, patinaDB answers with the real topology — writes to the leader, reads spread across the followers — so a routing driver offloads reads to replicas automatically. See High Availability → Cluster-aware Bolt routing for the full WRITE/READ/ROUTE breakdown and the lag-immune clean-tag read pattern. A single-node server returns itself for all three roles.

Behind a reverse proxy

Behind a TLS-terminating proxy or load balancer, set --advertised-addr to the public host:port so the routing table sends drivers to a reachable endpoint (each node advertises its own). For a direct bolt:// connection this isn’t needed — the default advertises the listen address. See Authentication & TLS.

Change Streams (CDC)

patinaDB can stream every committed graph change as it happens, so external systems can react to writes — invalidate a cache, sync a search index, feed a downstream ETL pipeline. This is Change Data Capture (CDC), and it is built directly on the engram log: every commit is an engram of resolved delta-ops, and the change stream is simply that log made subscribe-able.

CDC is a server (Raft) feature, exposed over HTTP as Server-Sent Events (SSE).

The endpoint

GET /changes?db=<name>&since=<engram-uuid>
  • db — the database to observe (default default).
  • since — a resume cursor: the engram id of the last change you already processed. The stream first replays every engram committed after that id, then live-tails new commits. Omit it (or pass 0) to start from the beginning of history.

The response is an SSE stream (Content-Type: text/event-stream). Each committed write arrives as one change event:

id: 6f9c…-a1b2            ← the engram id = your next resume cursor
event: change
data: {"engram_id":"6f9c…-a1b2","parent_id":"…","db":"default",
       "timestamp":1751900000,"author":"alice",
       "changes":[{"op":"createNode","id":"…","label":"Person"},
                  {"op":"setNodeProperty","id":"…","key":"age","value":30}]}

The SSE id: field is the engram id, so a standards-compliant SSE client resumes automatically via Last-Event-ID after a dropped connection; you can also pass it back explicitly as ?since=.

Change records

Each event’s changes array holds one compact record per delta-op:

opfields
createNode / deleteNodeid, label (on create)
createRel / deleteRelstart, end, type
setNodeProperty / setRelPropertyid or start/end/type, key, value
removeNodeProperty / removeRelPropertyid or start/end/type, key
setNodeLabelsid, labels (the full secondary-label set)

Resume & delivery semantics

Delivery is at-least-once with the engram cursor. On reconnect with since=<cursor>, the stream replays exactly the engrams after that cursor and then continues live. The server subscribes to the live feed before reading history, so no commit can slip through the gap between “read the past” and “start tailing”; the small replay/live overlap is de-duplicated internally, so a well-behaved consumer sees every engram after its cursor, with no gap and only a bounded, self-healing overlap.

Persist the last engram_id you successfully processed. If your consumer restarts, reconnect with it as since= and you continue exactly where you left off.

Lag

The stream is backed by a bounded in-memory buffer per database. A consumer that falls too far behind receives a lagged event:

event: lagged
data: {"resumeFrom":"6f9c…-a1b2"}

When this happens the server automatically re-reads the engram log from your last cursor (so no changes are lost) and continues. The lagged event is only a cue that a resync occurred.

Consistency (read this)

The change stream is node-local and eventually consistent, exactly like a consistency=local read:

  • It observes this node’s applied HEAD as it advances. On a follower it lags the leader slightly; it never reflects an uncommitted write.
  • Ordering within a database is the engram chain order (each event’s parent_id is the previous event’s engram_id).
  • Publishing a change never blocks replication — a slow or dead subscriber can never stall the write path; it just lags and resyncs.

For a single-writer-of-record consumer, subscribe to the leader (see cluster routing); a follower stream is fine for best-effort reactions where a small delay is acceptable.

Authorization

/changes is authorized as a read on the target database — the caller needs at least the Reader role for that db (see Authentication & RBAC). An unauthenticated request is rejected with 401; in closed-mode tenant isolation, an ungranted database is 403.

A fine-grained (per-label or strict-relationship) user cannot subscribe at all. A ChangeRecord streams raw property values and full label sets with no per-event label filtering, so the row-level filtering that protects an ordinary /cypher read (see per-label grants) can’t be applied to a live firehose — a subscriber holding any per-label grant, or any relationship grant under strict-rel mode, is refused the subscription outright (403) rather than risk it observing an ungranted label’s data. A user with only a blanket database role (no fine-grained grants) is unaffected and streams normally.

Example

# Tail the default database's changes (with credentials + resume cursor).
curl -N -u alice:apw \
  'http://localhost:8080/changes?db=default&since=6f9c…-a1b2'

-N disables curl’s buffering so events print as they arrive. Any SSE-capable client (browser EventSource, an SSE library in your language) works the same way.

Related: CDC is a live view over the same engram log that powers diffs and time travel. Where those read history on demand, CDC pushes each new engram as it commits.

Multi-Database

A single server node hosts multiple named databases, each an isolated graph with its own history. The --db flag points at a root directory; each database lives in its own subdirectory underneath it. A database named default always exists; system is reserved.

DDL

CREATE DATABASE sales
CREATE DATABASE IF NOT EXISTS sales
DROP DATABASE sales
DROP DATABASE IF EXISTS sales
SHOW DATABASES
  • Database names are case-insensitive, may be backtick-quoted, and are kept filesystem-safe.
  • CREATE DATABASE / DROP DATABASE are replicated as Raft control commands, so the set of databases is consistent across the cluster.
  • DROP DATABASE is idempotent and refuses to drop default.
  • SHOW DATABASES is a local registry read (also surfaced over Bolt for the Neo4j Browser’s database dropdown).

Selecting a database

Per query, choose the target database in any of three ways:

USE sales
MATCH (o:Order) RETURN count(o)
  • A leading USE <db> prefix (also USE <db> AS OF '<id>' for time travel).
  • The db field in a REST request body or the Bolt RUN/BEGIN metadata.
  • Otherwise the default database.

Selection is per-statement and never sticky across pooled Bolt connections.

Isolation

Databases are fully isolated — a write to default is invisible to sales and vice versa; there is no cross-database query or traversal. Each database has its own engram history, its own full-text indexes, and its own snapshots.

This isolation holds across failover: in a multi-node cluster, CREATE DATABASE replicates to all nodes, writes route to the correct isolated graph, and both databases survive a leader kill with post-failover writes still routing correctly (this is covered by the failover test suite).

Database count is entitlement-limited on the server. A Community-tier server caps the number of user-created databases (the reserved default and system never count against it); a CREATE DATABASE past the cap is refused. See Editions & Limits. A database that opts into Anamnesis provenance projection gets a companion database named <db>__anamnesis — it counts as ordinary registry data, not against your own database count.

High Availability

A single --bootstrap node is already a working server, but a quorum of one has no redundancy. Add nodes to get automatic failover: if the leader dies, the survivors elect a new one and writes continue.

Cluster sizing

Raft tolerates failures up to a quorum. Use an odd number of voters:

VotersTolerates failuresNotes
10--bootstrap; a plain server.
31The usual minimum for real HA.
52Higher availability, more replication.

Growing a cluster

Start one node with --bootstrap, then add peers as learners (they catch up without voting) and promote them to voters:

# Node 1 (bootstrap leader)
patinadb-raft --id 1 --addr 127.0.0.1:21001 --db ./n1 --bootstrap \
  --bolt-addr 127.0.0.1:7687 --auth-password secret

# Nodes 2 and 3 (no bootstrap — they join)
patinadb-raft --id 2 --addr 127.0.0.1:21002 --db ./n2 \
  --bolt-addr 127.0.0.1:7688 --auth-password secret
patinadb-raft --id 3 --addr 127.0.0.1:21003 --db ./n3 \
  --bolt-addr 127.0.0.1:7689 --auth-password secret

Then, against the leader’s --addr:

# Register the new nodes as learners (id → its HTTP addr)
curl -u neo4j:secret -X POST 127.0.0.1:21001/mgmt/add-learner \
  -H 'content-type: application/json' -d '{"id":2,"addr":"127.0.0.1:21002"}'
curl -u neo4j:secret -X POST 127.0.0.1:21001/mgmt/add-learner \
  -H 'content-type: application/json' -d '{"id":3,"addr":"127.0.0.1:21003"}'

# Promote to a 3-voter membership
curl -u neo4j:secret -X POST 127.0.0.1:21001/mgmt/change-membership \
  -H 'content-type: application/json' -d '[1,2,3]'

All nodes must share the same --auth-password — peer RPCs carry the same Basic credentials.

Auto-join with --join

Instead of running /mgmt/add-learner by hand, start a fresh node with --join <member> (the HTTP address of any current member). It registers itself as a learner (read replica) on startup, following a leader hint if --join points at a follower:

# Node 1 leads; nodes 2 and 3 auto-join as learners.
patinadb-raft --id 1 --addr 127.0.0.1:21001 --db ./n1 --bootstrap ...
patinadb-raft --id 2 --addr 127.0.0.1:21002 --db ./n2 --join 127.0.0.1:21001 ...
patinadb-raft --id 3 --addr 127.0.0.1:21003 --db ./n3 --join 127.0.0.1:21001 ...

--join is mutually exclusive with --bootstrap. The nodes join as learners; promote them to voters when you want them to count toward quorum:

curl -u neo4j:secret -X POST 127.0.0.1:21001/mgmt/change-membership \
  -H 'content-type: application/json' -d '[1,2,3]'

Failover behaviour

  • Election timeout is 750–1500 ms with a 250 ms heartbeat. When the leader stops heartbeating, a survivor wins an election and takes over.
  • Clients connected to the dead node reconnect to a survivor (neo4j:// routing drivers do this automatically — see Bolt on --advertised-addr).
  • Committed writes are durable on a quorum and survive the failover; in-flight uncommitted writes to the dead leader may need to be retried.

The failover test suite exercises exactly this: a 3-node cluster, a write through the leader, kill the leader, and assert the survivors elect a new leader, writes continue, and the cluster retains all records — including the multi-database case. A companion test drives sequential writes plus linearizable reads across a single leader kill and checks that no acknowledged write is lost across the failover and that the linearizable-read count is monotonic (never decreases). This is a solid failover + acked-write-durability smoke test.

Scope of the durability test. The linearizable-read test issues writes sequentially, uses idempotent MERGE (which masks any accidental double-apply), and kills the leader once — there are no concurrent clients, no network partitions, and no operation reordering. It verifies that acked writes survive a single failover and that read counts stay monotonic; it is not a linearizability check in the Jepsen sense. True concurrent-history linearizability testing (Knossos/Elle-style history checking under concurrency, partitions, and reordering) is future work.

Removing a dead voter

A permanently-unreachable voter stays in the quorum set and blocks further membership changes. Evict it on the leader with a live-quorum guard:

curl -u neo4j:secret -X POST 127.0.0.1:21001/mgmt/evict-voter \
     -H 'content-type: application/json' -d '{"id": 3}'

The endpoint demotes-then-removes the voter in one membership change and refuses (409) if doing so would leave the surviving voters unable to form a quorum. See Day-2 Operations for the full contract, plus the /health vs /ready probes and request-id tracing.

Learners as read replicas

A learner replicates the log and applies it locally but does not vote. Since any node serves reads from its own applied state, learners act as asynchronous read replicas. The single-node, learner, and voter cases are all the same binary and the same apply path.

Cluster-aware Bolt routing (reads → followers, writes → leader)

A Neo4j routing driver (neo4j:// scheme) asks the cluster for a routing table and then load-balances: it sends writes to a WRITE server and spreads reads over the READ servers. patinaDB answers that request — over both the native Bolt ROUTE message and the dbms.routing.getRoutingTable procedure — with the real cluster topology:

RoleServers returned
WRITEthe leader’s advertised Bolt address (only it writes)
READevery non-leader member (followers + learners)
ROUTEall members (any node can answer a routing request)

The effect: neo4j:// clients automatically offload reads to the followers and send writes to the leader — read scaling with no application changes. On a single-node --bootstrap cluster the leader is the only member, so all three roles resolve to that one node (no change from a standalone server).

Each node learns its peers’ advertised Bolt addresses from a background poll of every peer’s GET /version (which now reports advertised_bolt_addr) — the same poller that tracks peer protocol versions. Because the addresses returned to drivers are the --advertised-addr values, set that to each node’s public host:port when running behind a TLS-terminating proxy, so routing stays reachable (see Bolt). If the leader is momentarily unknown (an election is in flight) WRITE falls back to the local node — the client then gets the leader-hint 503 on the misrouted write and retries.

A follower read is eventually consistent (it serves that node’s local applied state, which may lag the leader). That is exactly what you want for analytics and browsing. When you need a read that is both lag-immune and free to load-balance across followers, use the clean-tag pattern below.

Read consistency

By default a read serves the local applied state of whichever node you hit:

  • Causally consistent within a database — the Raft log is a total order, so a single node never sees writes out of order.
  • Eventually consistent across replicas — a follower or learner that lags the leader’s commit index may not yet reflect a write that has already committed elsewhere. Reads are fast (no cluster round-trip).

When you need a read to reflect every write committed before it began, opt in to a linearizable read on the REST /cypher endpoint:

curl -u neo4j:secret -X POST 127.0.0.1:21001/cypher \
  -H 'content-type: application/json' \
  -d '{"query":"MATCH (n:Person) RETURN n","consistency":"linearizable"}'

This routes through a leader read-index barrier: the leader confirms it is still leader (a heartbeat to a quorum), waits until it has applied the current commit index, then runs the read. Only the leader can serve a linearizable read — sending one to a follower returns a 503 whose body names the leader (leader_id / leader_addr), exactly like a misrouted write. It costs one intra-cluster round-trip; the default "local" read skips it.

consistency is a REST-only knob. The Bolt path always serves the default local read, but a standard Neo4j driver session gets read-your-writes automatically there too, via causal bookmarks — no consistency field to set. See Bolt → Causal consistency for the mechanism.

Summary — which surface gives which guarantee:

SurfaceGuarantee
Bolt driver session (bookmarks)Read-your-writes — automatic, bounded-wait. No client change needed.
REST POST /cypher, "consistency":"local" (default)Eventually consistent on a follower; fast, no wait.
REST POST /cypher, "consistency":"linearizable"Read-your-latest-write against live HEAD — leader-only, costs a round-trip.
A plain follower read with no bookmark / no linearizable flagEventually consistent — bounded staleness (the routing table above already excludes chronically-lagging followers).

Lag-immune reads across followers: the clean-tag pattern

A linearizable read pins you to the leader, which defeats read scaling. When you want a consistent, lag-immune read that can still load-balance freely across followers, read as of a named tag instead:

USE mydb AS OF TAG 'nightly-2026-07-04'
MATCH (n:Person) RETURN n

An engram tag names a specific point in history. Tags are replicated, snapshotted, and deterministic, so reading AS OF TAG '<name>' returns bit-identical results on every node regardless of replication lag — a follower that is behind on new writes still reconstructs the tagged state exactly. This gives you a stable, reproducible read that any follower can serve:

  • Point routing-driver reads at the followers (automatic — see above).
  • Tag a known-good state (e.g. after a nightly load) and have reporting/analytics read AS OF TAG that tag.
  • Every replica agrees on the answer, and no read has to touch the leader.

See Time Travel and Engrams for creating and managing tags. Use consistency: linearizable (above) only when you specifically need read-your-latest-write against the live HEAD.

Rolling upgrades

Nodes in a cluster exchange a versioned wire/disk protocol: the Raft log entry payload (AppRequest), its response, and the streamed-snapshot record format. That format is append-only and versioned, which is what makes a rolling upgrade (upgrade one node at a time, no full-cluster downtime) safe as long as you upgrade in the right order.

What the format guarantees

  • Append-only log-entry variants. New kinds of replicated command are only ever appended to the AppRequest enum, never inserted or reordered. Log entries are stored positionally (bincode), so reordering would silently re-map already-persisted entries; a build-time test pins the exact order and count to prevent it. A newer node can therefore always decode an older node’s entries.
  • Observable protocol version. Each node reports a protocol_version on its GET /version endpoint. It bumps whenever a new variant (or other wire/disk change) lands, so you can confirm what every node speaks before and during an upgrade.
  • Automatic capability gate. The leader will not propose a command that some cluster member is too old to apply. Each node polls every peer’s GET /version in the background and tracks the cluster-wide minimum protocol version. Before a command is appended to the Raft log, the leader checks the version that command requires against that minimum: if any member is older — or its version hasn’t been confirmed yet (conservative: unknown is treated as too old) — the proposal is rejected without being written (409 on REST, a failure with the same message on Bolt) telling you to finish the upgrade first. This gate is live and load-bearing today, not just future-proofing — most commands (a plain graph write) only need the baseline version, but several newer features (per-label/relationship-type RBAC grants, CREATE FUNCTION … LANGUAGE wasm) are pinned to a higher minimum protocol version and are correctly blocked from proposing until every member has upgraded past it.
  • Loud rejection of an unknown variant. If an older node receives a peer RPC carrying a command it does not know how to decode (because a newer leader emitted it), it logs a clear error — “unknown AppRequest variant — this node is older than the leader; upgrade it” — and returns 422, instead of a silent or opaque failure. A mixed-version wedge is diagnosable from the logs.
  • Snapshot format guard. The streamed snapshot carries a format_version for its record stream. On install, a node refuses an unknown (newer) version before clearing its graph, so a version-skewed snapshot can never tear a half-restored store — the node keeps its existing data and can retry once upgraded.

Upgrade order

Upgrade the nodes one at a time (each catches back up before you move on); the leader can be upgraded last or stepped down first. You no longer have to time it perfectly: the capability gate blocks any command a not-yet-upgraded member couldn’t apply, so exercising a new feature too early fails cleanly (409 / Bolt failure, “upgrade all nodes first”) instead of wedging a node. Once every node reports the new protocol_version on GET /version, the gate opens on its own and the new functionality just works.

Check GET /version on every node to see when they all agree on protocol_version.

Scope of the guarantees

The capability gate is the leader-side safeguard: it refuses to emit a command until it has confirmed every member can apply it (unconfirmed peers block conservatively). The append-only variant discipline and the loud 422 on an unknown variant remain the defence in depth if a command ever does reach an older node, and a version-skewed snapshot is still refused before it can clear a graph. Finer-grained, per-feature negotiation (beyond a single monotonic protocol version) is possible future work.

Causal bookmarks during a mixed-version rollout. An old-version node ignores an inbound Bolt session bookmark and emits a meaningless stub instead of a real one. So while a rolling upgrade is in progress, a session that lands a write on an already-upgraded node and then a read on a not-yet-upgraded one can still observe a stale result — this is transient (it clears once that node is upgraded) and does not affect data safety, but it is worth knowing about if you rely on read-your-writes during the upgrade window itself.

Day-2 Operations

Once a cluster is up, three things make it operable: a probe that tells a load balancer or Kubernetes when a node can actually serve, request correlation so a slow query is traceable end-to-end, and a way to remove a permanently-dead voter so it stops blocking membership changes and quorum math.

Backups, RPO/RTO planning, and step-by-step recovery for a lost node, a lost leader, or a whole-cluster restore build on the /mgmt/snapshot / /mgmt/restore mechanics in Production Deployment → Backup & disaster recovery and the removal procedure below — ask your patinaDB contact for the full disaster-recovery runbook if you don’t already have it.

Health vs. readiness probes

The server exposes two unauthenticated probe endpoints. Both are auth-exempt (like /version / /metrics) so a load balancer or orchestrator can reach them without credentials, and neither is ever shed by the concurrency limiter.

EndpointSemanticsPoint it at
GET /healthLiveness200 whenever the process is up, always.Kubernetes liveness probe (restart the container if it stops answering).
GET /readyReadiness200 only when the node can actually serve.Kubernetes readiness probe / load-balancer health check / neo4j:// read rotation.

/ready returns 200 with {"ready": true, …} only when all of the following hold, and otherwise 503 with a short machine-readable reason (checked in priority order):

reasonMeaning
no_leaderNo known leader (fresh node, or mid-election).
installingThe node is installing a Raft snapshot (graph being rebuilt).
degradedCommunity-mode write-degrade: the node couldn’t phone home within the grace window (see Licensing & Telemetry).
lagginglast_log_index − last_applied exceeds --readiness-max-lag (default 50) — this replica is behind.
sheddingThe concurrency limiter (--max-concurrent-requests) is saturated.

Example bodies:

// GET /ready  → 200
{ "ready": true, "current_leader": 1, "last_applied": 42, "apply_lag": 0 }

// GET /ready  → 503 (fresh node, no leader yet)
{ "ready": false, "reason": "no_leader", "current_leader": null, "last_applied": 0, "apply_lag": 0 }

Because /ready fails when a node is leaderless, lagging, mid-install, or degraded, pointing a load balancer at it keeps stale/failing reads out of the rotation. Keep /health as the liveness probe so a slow-but-catching-up node is not restarted while it recovers.

The distroless image ships without a shell, so the compose healthcheck can’t curl from inside the container — probe /health and /ready from the orchestrator / an external monitor instead.

Kubernetes example

livenessProbe:
  httpGet: { path: /health, port: 8080 }
  periodSeconds: 10
readinessProbe:
  httpGet: { path: /ready, port: 8080 }
  periodSeconds: 5

Request correlation & tracing

Every HTTP request runs inside a tracing span carrying a request id:

  • If the caller sends an X-Request-Id header it is honored (used verbatim); otherwise a fresh UUID is minted.
  • The id is echoed on the response X-Request-Id header, and it appears on every log line emitted while handling the request — so a slow or failing query is traceable from the client through the server logs.
  • An inbound W3C traceparent header’s trace_id is picked up into the span as trace_id, for OpenTelemetry-compatible correlation across services.

Setting --otel-endpoint <url> (or PATINADB_OTEL_ENDPOINT, e.g. http://otel-collector:4318) installs a real OTLP span exporter (OTLP/HTTP protobuf, /v1/traces is appended automatically) — every http_request span (carrying request_id and any inbound trace_id) is exported to the configured collector, and outbound /raft/* peer RPCs propagate a W3C traceparent derived from the current span’s context, so a trace that enters on one node continues across the Raft round-trip to whichever node actually applies it. This is independent of PATINADB_LOG=json (structured JSON logging) — set either, both, or neither; X-Request-Id correlation is always on regardless of this flag. A malformed or unreachable endpoint logs a warning at startup and the node runs normally with span export simply disabled (exporter init failure is never fatal, and once initialized, network failures during actual export are async/best-effort and never block a request). With no endpoint set, nothing OTLP-related is installed — zero cost.

Telemetry-degrade observability and break-glass

A community-mode node that can’t reach the telemetry endpoint for the whole grace window (default 72h) degrades: client writes are refused until a heartbeat succeeds again (see Licensing & Telemetry). Two things make that freeze operable instead of a surprise:

  • Alert before it happens. /metrics exposes patinadb_telemetry_degraded (0/1, always present) and patinadb_telemetry_seconds_until_degrade (present only while community mode is armed and not yet degraded) — wire a Prometheus alert on the latter dropping below, say, one hour so you learn about a telemetry-endpoint outage well before writes actually stop.

  • A time-boxed break-glass override, for the rare case where you need writes to keep flowing through a telemetry outage you can’t fix immediately:

    patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
      --auth-password "$PW" \
      --telemetry-degrade-override-until 72h
    

    Flag --telemetry-degrade-override-until <value> / env PATINADB_TELEMETRY_DEGRADE_OVERRIDE. The value is always resolved to an absolute deadline, so the override must expire — accepted forms:

    FormExampleResolves to
    Duration from startup72h, 3d, 30m, 90snow + duration
    RFC3339 UTC timestamp2026-07-12T09:00:00Zthat instant
    Bare unix-second integer1799999999that instant

    While the override is active, /metrics also exposes patinadb_telemetry_override_until_seconds (seconds remaining before the override itself expires) so you don’t lose track of it. The override is logged loudly at startup — it is a deliberate, visible escape hatch, not a silent bypass. Once it expires, the normal grace-window degrade behavior resumes exactly as if it had never been set.

Tenant isolation and read-proc guards

Two related operational knobs, covered in full elsewhere but worth knowing about when running a shared/multi-tenant cluster:

  • --rbac-closed (database-level deny) stops a non-admin’s global role from reaching every database by default — see Authentication & TLS: database-level deny.
  • Expensive read procedures are bounded, not unlimited. CALL patinadb.algo.betweenness/closeness (O(V·E), Reader-callable) are guarded by a cooperative deadline under --query-timeout-secs (the algorithm checks in periodically and bails cleanly instead of running past the timeout to completion) plus a static work budget, PATINADB_MAX_ALGO_WORK (env, default ~1e9 — see Configuration Reference), so a Reader can’t pin a blocking thread indefinitely even with no --query-timeout-secs set. Not yet implemented: a bounded blocking-thread pool and a per-user concurrent-read-procedure cap — today’s guards stop a single expensive call from running forever, but a burst of many concurrent expensive calls from different users is not yet rate-limited.

Removing a dead voter

A permanently-unreachable voter stays in the quorum set until you remove it, and it can block further membership changes. In a 3-voter cluster, one dead voter plus one more failure is a quorum loss — so evict a node you don’t intend to bring back.

curl -u admin:… -X POST http://<leader>/mgmt/evict-voter \
     -H 'content-type: application/json' -d '{"id": 3}'
# → 200 {"ok": true, "evicted": 3, "voters": [1, 2]}
  • Admin-only (under /mgmt/), and must be issued on the leader — a follower returns a 503 leader hint (like a misrouted write).
  • It performs a demote-then-remove in one joint-consensus membership change.
  • Quorum guard: the request is refused (409) if the resulting voter set’s live members could no longer form a majority. “Live” requires both reachability over GET /health and genuine Raft replication progress (a voter that answers /health but has fallen behind — or dropped out of — the leader’s replication view is not counted live), so the endpoint won’t hand you a cluster that can’t actually commit. Evicting an unknown id, or the last remaining voter, is a 400.

Automatic dead-voter eviction

A leader-only background failure detector can auto-evict a voter that stays dead past a configurable window, reusing the exact same quorum guard as the manual endpoint above — it can never strand the cluster.

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --auth-password "$PW" \
  --auto-evict-after-secs 300
  • Off by default (unset or 0) — no background task runs, behavior is byte-for-byte the manual-only path above.
  • A voter must be continuously dead for the whole window before it’s evicted (a single bad probe never triggers it — the timer resets the instant the voter is seen alive again).
  • If eviction would break quorum, the detector refuses and backs off — it never forces the removal. Every attempt (allowed or refused) is recorded in the audit log.

Learner→voter auto-promotion is a follow-on. Use GET /mgmt/cluster to see the live voter/learner topology before and after any eviction.

Reclaiming disk from a bloated .redb file

patinaDB’s B-tree storage engine pre-allocates its file and reuses freed pages internally — it never shrinks the file on its own. So after a bulk-load-then- delete, a mass DETACH DELETE, dropping a large label, or a retention squash, the per-db .redb MAIN file can stay far larger than the live graph, and the usual remedies do not touch it:

  • PATINADB_MAX_SNAPSHOTS / a licensed node’s history_retention_days squash pass free only the separate _engram_snapshots/*.snap sidecar files.
  • Deleting data frees pages inside the file (the store reuses them for the next write) but does not return them to the filesystem.

If GET /mgmt/dbsizes shows a database’s .redb file itself is the bloat (not its snapshot sidecars), reclaim it with one of the two paths below.

Offline: patinadb <db> compact

The patinadb maintenance tool (shipped alongside the server) provides a subcommand that runs the storage engine’s built-in compaction directly on a node’s .redb file:

patinadb ./data/mydb compact
# compacted ./data/mydb: 46141440 -> 6295552 allocated bytes (39845888 freed, 86.4%)

This is OFFLINE ONLY — the target database must not be open anywhere else (a running patinadb-raft node, or another patinadb process holding the same file open). Compaction needs exclusive access — no concurrent readers or writers at all (an inherent property of the single-file B-tree) — so it cannot safely run against a live server. There is deliberately no /mgmt/compact REST endpoint or live/online compaction path — attempting to open a database that’s still in use fails loud with a clear “database already open” error and touches nothing (never corruption, never a partial compaction). To use it against a clustered node: stop that one node, run compact, then start it back up (a follower rejoins and catches up normally; see the rolling rebuild below for a way to do this with zero downtime across the whole cluster).

Compaction relocates and frees pages, then shrinks the file — it changes only the physical file, never any logical content, so every graph, engram-history record, and index reads back byte-identically afterward; AS OF time-travel, constraints, and compound/fulltext/vector indexes are all unaffected.

Measured (this box; a 50,000-node graph, each node with 3 string properties, then 49,500 of them DETACH DELETEd — the allocated-block figures below are blocks() * 512, never metadata().len(), per this repo’s measurement discipline, since the storage engine’s sparse pre-allocated tail makes len() over-report):

allocated bytes
after the 50k-node load27,398,144
after deleting 49,500 of them (pre-compact)46,141,440
after patinadb ./db compact6,295,552

A 7.3× reduction (86.4% of the allocated bytes reclaimed) for this shape; the exact ratio depends on how much of the file’s high-water mark is genuinely dead versus reused-in-place, so re-measure on your own data rather than assuming this number.

Zero-downtime: rolling rebuild onto a fresh directory

For a clustered node you don’t want to take fully offline, the same effect is achieved by rebuilding a follower from scratch via the existing streamed- snapshot bootstrap (docs/disaster-recovery.md covers the mechanics for a lost node; this is the identical procedure used deliberately, as a space-reclamation step, on a node that isn’t lost):

  1. Stop the bloated follower and remove (or move aside) its --db data directory — a fresh, empty directory in its place.
  2. Start it back up with the same --id/--addr; it rejoins as before and add-learner/normal catch-up streams a snapshot into it, which writes a compact file (no bloat — it’s built fresh from the current graph, not accumulated by however many past deletes/updates the old file absorbed).
  3. Once it’s caught up, fail over onto it (or just leave it as a healthy follower) and repeat for the next node — one at a time, so the cluster never loses quorum.
  4. Finally rebuild the former leader the same way once it’s no longer serving writes for that database.

This needs no new tooling — it’s the same rejoin path a genuinely lost node uses — and, unlike the offline compact command, it never takes the cluster down (only the one node being rebuilt, which the remaining voters cover for).

An allocated-vs-live-bytes gauge (so bloat is visible before it becomes a disk alert) is a documented follow-up, not implemented yet — for now, GET /mgmt/dbsizes (the on-disk size) alongside a rough live-graph estimate from MATCH (n) RETURN count(n) / GET /version’s usage block is the manual signal to watch for a growing gap between the two.

Authentication & TLS

Authentication

The server uses a single shared credential (--auth-user / --auth-password, or the PATINADB_AUTH_PASSWORD environment variable).

  • Enabled when a password is set. An empty password means no authentication, which is fail-closed: the node refuses to start unless you also pass --insecure-disable-auth. This prevents accidentally exposing an open node by forgetting to set a password. With the flag, the node runs open and logs a prominent warning — only acceptable on a trusted, firewalled, single-tenant network (ideally still behind a TLS-terminating proxy).

    # Refuses to start (no password, no opt-in):
    patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap
    # → Error: refusing to start with authentication disabled: set --auth-password …
    
    # Deliberately open (trusted network only):
    patinadb-raft --id 1 --addr 127.0.0.1:21001 --db ./data --bootstrap \
      --insecure-disable-auth
    
  • REST: HTTP Basic on every route except the open probes /health and /version (and /metrics only if you opt out of metrics auth — see below).

  • /metrics: authenticated by default — the exposition series carry db=<name> labels, so an open endpoint would let an unauthenticated scraper enumerate every database name and its per-db volume. It sits behind the same Basic auth as every other route. To serve it open on a private, trusted monitoring network, pass --insecure-open-metrics (or set insecure_open_metrics: true / the legacy require_metrics_auth: false in the config file). /health and /version stay open regardless.

  • Bolt: checked at LOGON (scheme: "basic"); an unauthenticated connection cannot run queries.

  • Peer RPCs: the /raft/* inter-node calls authenticate with a dedicated cluster secret (--cluster-secret / PATINADB_CLUSTER_SECRET, sent in the X-Cluster-Secret header), separate from the root-admin credential so the two rotate independently, and all cluster nodes must share the same cluster secret. A clustered node (not --bootstrap) with authentication enabled now refuses to start without an explicit --cluster-secret (issue #446): if it silently fell back to the admin password, a leaked admin password would also be a valid peer-RPC credential, and rotating one would silently affect the other. Set --cluster-secret to a distinct value — you may set it to the current admin password to keep the same effective secret while decoupling the two. A single --bootstrap node has no peers (the secret is moot), so it may omit it, with a startup warning to set one before growing into a cluster. Migrating an existing single-credential cluster: add --cluster-secret <value> (or PATINADB_CLUSTER_SECRET, or the config-file cluster_secret) to every node before restart.

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --auth-user neo4j --auth-password "$PATINADB_AUTH_PASSWORD"

Users & roles (RBAC)

Beyond the shared credential, the server supports per-user accounts with roles. The configured --auth-user / --auth-password is the built-in root admin (always accepted — you can’t lock yourself out); additional users are created at runtime and replicate across the cluster.

Roles are global and ordered by privilege:

RoleCan do
readerread-only Cypher (MATCH … RETURN)
writerreads and writes (CREATE/SET/DELETE/MERGE)
admineverything: writes, all DDL, cluster /mgmt/*, user management

Manage users via Cypher-style DDL (admin only), e.g. over REST:

# As the root admin:
curl -u neo4j:secret -X POST http://127.0.0.1:21001/cypher \
  -H 'content-type: application/json' \
  -d "{\"query\":\"CREATE USER alice SET PASSWORD 'apw' SET ROLE writer\"}"

# alice can now write but not manage users or the cluster:
curl -u alice:apw  -X POST http://127.0.0.1:21001/cypher -d '{"query":"CREATE (n:Person)"}' ...   # 200
curl -u alice:apw  -X POST http://127.0.0.1:21001/cypher -d '{"query":"CREATE USER eve …"}' ...    # 403
  • CREATE USER <name> SET PASSWORD '<pw>' [SET ROLE <role>] (default role reader)
  • ALTER USER <name> SET PASSWORD '<pw>' | SET ROLE <role>
  • DROP USER <name>, SHOW USERS

User changes replicate through Raft (passwords are argon2-hashed on the leader) and are carried in snapshots. Enforcement applies to both REST and Bolt. Wrong credentials → 401; insufficient role → 403.

Per-database roles

A user has a global default role plus optional per-database overrides. The effective role on database X is the override for X if set, otherwise the global role — so an override can both elevate and restrict a user on a specific database:

# alice is a global reader…
curl -u neo4j:secret … -d "{\"query\":\"CREATE USER alice SET PASSWORD 'apw' SET ROLE reader\"}"
# …but a writer on the `sales` database only:
curl -u neo4j:secret … -d "{\"query\":\"GRANT writer ON DATABASE sales TO alice\"}"
# revoke it again:
curl -u neo4j:secret … -d "{\"query\":\"REVOKE ON DATABASE sales FROM alice\"}"
  • GRANT <role> ON DATABASE <db> TO <user>
  • REVOKE [<role>] ON DATABASE <db> FROM <user>

Per-database roles govern data reads/writes on that database. Cluster management (/mgmt/*), database/user DDL, and GRANT/REVOKE themselves always require the global admin role. SHOW USERS reports each user’s db_roles.

Database-level deny (closed-mode tenant isolation)

By default the role lattice bottoms out at readerevery authenticated, non-admin user can USE any database and read it, including a database it was never granted anything on. For a genuinely multi-tenant deployment, opt into closed-mode RBAC:

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --auth-password "$PW" --rbac-closed
  • Flag --rbac-closed / env PATINADB_RBAC_CLOSED / config key rbac_closed. Off by default — enabling it is a real behavior change: a non-admin whose only credential is a global role loses access to every database it holds no explicit grant on (a per-database role override, or a per-label grant). That loss of blanket access is the isolation.
  • A global admin, the root credential, and (if you disabled auth entirely) the open-node case are always unaffected — closed mode only narrows non-admin access.
  • Enforced identically on both transports — REST (server::authorize_data, which also covers the /changes CDC stream) and Bolt (against the resolved USE-target) — with a Neo.ClientError.Security.Forbidden (403 / Bolt failure) on a denied database.
  • Genuine anamnesis companions are bound to their base database. A <db>__anamnesis provenance companion has no grants of its own; closed mode resolves it back to <db> for the authorization check — a user who can read sales can read sales__anamnesis, and a user with no access to sales cannot reach its companion either. A label-scoped user is authorized against the base db’s label grants, which it typically doesn’t hold for the companion’s synthetic PROV labels — so companions fail closed for label-scoped users unless explicitly granted. Only a genuine companion inherits the base grant (issue #445): the base-binding fires solely when the base database exists and has provenance enabled (i.e. the companion was created by CALL patinadb.anamnesis.enable()). A database that merely ends in __anamnesis but is not a genuine companion governs itself — so a real db named sales__anamnesis is not reachable via a sales grant. As defence in depth the reserved __anamnesis suffix is rejected at CREATE DATABASE / FORK. (Disabling provenance keeps the companion db but makes it self-govern for authorization until re-enabled — fail-closed.)

Combine it with per-database roles or per-label grants (below) to give each tenant exactly the databases/labels it needs, with everything else invisible.

Per-label grants (fine-grained RBAC)

For finer control than a per-database role, grant a user READ or WRITE on a specific label in a database:

# alice may read Person nodes in `sales`, and write Ticket nodes there:
curl -u neo4j:secret … -d "{\"query\":\"GRANT READ ON sales:Person TO alice\"}"
curl -u neo4j:secret … -d "{\"query\":\"GRANT WRITE ON sales:Ticket TO alice\"}"
# revoke one privilege:
curl -u neo4j:secret … -d "{\"query\":\"REVOKE WRITE ON sales:Ticket FROM alice\"}"
  • GRANT READ|WRITE ON <db>:<Label> TO <user>
  • REVOKE READ|WRITE ON <db>:<Label> FROM <user>

A WRITE grant implies READ (you can’t write a label you can’t read).

When a user has any per-label grant for a database, data queries against that database are authorized per label instead of by the blanket db-role. The query’s touched node labels are extracted and checked: every label it reads needs READ, every label it writes (CREATE / MERGE / SET n:Label / REMOVE n:Label / DELETE) needs WRITE. A user with only a (global or per-db) role and no label grants is unaffected — full db access exactly as before.

Enforcement is REJECT, not row-filtering: a query that touches an ungranted label — or an unclassifiable node set (an all-graph MATCH (n), a CALL procedure that reads arbitrary labels, an unlabelled CREATE, or a DELETE whose target label isn’t statically known) — is refused with Neo.ClientError.Security.Forbidden (403 on REST, a failure over Bolt). The label extractor is default-deny: anything it cannot statically classify is rejected, so a missed label can never become a silent grant. Enforcement is identical on REST and Bolt (a shared code path) and on every node (grants replicate through Raft and are snapshot-carried, so each replica decides the same way). SHOW USERS reports each user’s label_grants (db → { label → "r"/"rw" }).

REJECT vs row-filtering — actually a mix, per query shape. A write that touches an ungranted label is always rejected outright (a write can’t be partially applied). A read is more precise: when it touches only granted labels it runs normally; when it would also touch an ungranted label, it returns an empty result (not a 403) instead of a partial one — the ungranted data is treated as if it doesn’t exist, so an aggregation, EXISTS, traversal, or COUNT over it comes back empty rather than leaking a value. Only a genuinely unclassifiable read (an all-graph MATCH (n) with no label, or a CALL to a procedure that can touch arbitrary labels) is rejected with 403, since patinaDB can’t statically prove which labels it would touch and so can’t safely return an empty-but-correct result. A query that projects a whole matched entity (RETURN p, RETURN *, labels(p), properties(p), collect(p), a returned path, …) is also treated conservatively — a node’s ungranted secondary label (added via SET n:Label) isn’t visible to static analysis, so a whole-entity projection returns empty rather than risk leaking it; project explicit properties (RETURN p.name) instead. Per-property privileges (as opposed to per-relationship-property, below) are a documented follow-on.

Relationship-type and relationship-property RBAC (opt-in)

Per-label grants above cover node visibility only — a label-scoped user still reads and writes every relationship type via a traversal. Two further, independently-enabled, opt-in modes close that gap; both are off by default so no existing deployment’s traversals break when you upgrade.

Relationship-type grants--rbac-rel-grants (env PATINADB_RBAC_REL_GRANTS) enables enforcement of a per-relationship-type grant, using the bracketed form to disambiguate a type from a label:

# alice may traverse/create :KNOWS edges in `social`, but not any other type:
curl -u neo4j:secret … -d "{\"query\":\"GRANT WRITE ON social:[KNOWS] TO alice\"}"
  • GRANT READ|WRITE ON <db>:[TYPE] TO <user> / REVOKE … FROM <user>.
  • With the mode off (the default), a label-scoped user traverses any relationship type freely, unchanged. With it on, a query touching an untyped edge (-->, -[r]-> with no type, [*]) is always denied — the grant is per exact type, and an unclassifiable edge can’t be proven safe.
  • Enforcement is reject-based, not row-filtered (unlike the per-label read case above): a query touching an ungranted relationship type is refused outright.

Relationship-property grants--rbac-rel-property-grants (env PATINADB_RBAC_REL_PROPERTY_GRANTS) is a finer, independent layer: even with a relationship type granted, an individual property on that type can still require its own grant:

# alice may read the KNOWS.since property, but not KNOWS.secret:
curl -u neo4j:secret … -d "{\"query\":\"GRANT READ ON social:[KNOWS].since TO alice\"}"
  • GRANT READ|WRITE ON <db>:[TYPE].<prop> TO <user> / REVOKE … FROM <user>.
  • Independent of --rbac-rel-grants — enable either, both, or neither.
  • A whole-relationship access (RETURN r, properties(r), a dynamic r[$key]) or an untyped edge is denied outright — as with rel-type grants, there’s no way to statically prove “every property” is safe to grant.

Both modes replicate across the cluster and are snapshot-carried, just like per-label grants; SHOW USERS reports rel_grants/rel_property_grants alongside label_grants.

Security audit log

Every authenticated write / admin / DDL operation and every authorization denial is recorded to an audit log (who, when, action, database, allow/deny, and a literal-collapsed statement fingerprint — so passwords in CREATE USER never appear). Both transports feed it: the REST /cypher choke-point and the Bolt RUN authorization choke-point both record through the same shared dispatch::classify + audit machinery, so a denial or a write/DDL issued over a raw Bolt connection (a driver, or the Neo4j Browser) shows up in the trail exactly like a REST one — Bolt is no longer a blind spot. Read it back, newest-first (admin-only):

curl -u neo4j:secret http://localhost:8080/mgmt/audit?limit=100

Each event is also emitted to tracing (target patinadb::audit) for a centralized trail via your log pipeline. The log is durable — every entry is persisted to the node’s own on-disk store (fsynced before the write/denial that produced it returns), so it survives a restart; it is not just an in-memory ring. Retention is bounded (--audit-max-entries, default 100,000 — oldest entries are pruned past the cap; 0 = unlimited). Scope (honest): the trail is node-local, NOT Raft-replicated — a denial/write hit exactly one node, so there is no single cluster-wide audit view (ship each node’s tracing output to a central sink for that). Successful reads are not recorded (writes + denials only) — read-operation auditing is a follow-on. Encryption-at-rest for the audit trail and the graph is a storage-backend concern and is out of scope — use OS-level disk encryption today.

TLS

Native TLS for the HTTP plane

Pass --tls-cert + --tls-key (PEM) to serve the REST / management / Cypher API and the inter-node Raft RPCs (they share --addr) over HTTPS. Peers then talk https:// to each other and verify the certificate.

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --tls-cert /etc/patinadb/server.pem \
  --tls-key  /etc/patinadb/server.key \
  --tls-ca   /etc/patinadb/ca.pem \
  --auth-password "$PATINADB_AUTH_PASSWORD"
  • The certificate’s SANs must cover the peer --addr hosts (IPs/hostnames a peer dials), or peer verification fails.
  • --tls-ca is the CA peers verify each other with — for a self-signed / private-CA cluster, the cert (or CA) that signed every node’s --tls-cert. Omit it when node certs chain to a public CA (system roots are used).
  • TLS is opt-in: with no flags, the node serves plaintext (use the reverse proxy stance below).
  • TLS (native or via a reverse proxy) is required for production. With no transport encryption, REST Basic credentials and Bolt LOGON credentials cross the wire in cleartext on every request — a long-lived, replayable secret an on-path attacker can simply capture. If authentication is enabled (--auth-password, i.e. not --insecure-disable-auth) and neither --tls-cert/--tls-key nor a TLS proxy is configured, the node logs a loud startup warning naming exactly this risk (this fires for a single node too, not just a cluster — the cluster-secret warning above is a separate, additional one that only applies when peers are configured). The node does not refuse to start — a TLS-terminating reverse proxy in front of a plaintext node is a legitimate, supported deployment — but plaintext with no proxy in front of it is not something to run in production.

The same --tls-cert/--tls-key also secures the Bolt endpoint: it terminates TLS before dispatching, so native drivers (bolt+s:// / neo4j+s://) and the Neo4j Browser (wss://) both connect over TLS. The certificate must cover the Bolt host clients dial (see --advertised-addr for routing behind a proxy).

Reverse-proxy termination (alternative / for Bolt)

You can instead terminate TLS at a reverse proxy (nginx, Caddy, HAProxy, a cloud load balancer) — required for encrypted Bolt today:

  • Terminate https:// in front of the REST --addr (or use native TLS above).
  • Terminate bolt+s:// / neo4j+s:// (or wss for the Browser) in front of the Bolt --bolt-addr.

--advertised-addr behind a proxy

neo4j:// routing drivers fetch a routing table and then connect to the address the server advertises. Behind a proxy, the listen address is not the address clients should use, so set --advertised-addr to the public host:port:

patinadb-raft --id 1 --addr 0.0.0.0:21001 --db ./data --bootstrap \
  --bolt-addr 127.0.0.1:7687 \
  --advertised-addr graph.example.com:7687 \
  --auth-password "$PATINADB_AUTH_PASSWORD"

Now routing sends drivers to graph.example.com:7687 (your proxy), which terminates TLS and forwards to the node. For a direct bolt:// connection with no proxy, leave --advertised-addr unset (it defaults to --bolt-addr).

Hardening against malformed input

The Bolt wire decoder (packstream) parses attacker-controlled bytes before authentication succeeds — a HELLO/LOGON handshake is unauthenticated by definition. A length-prefixed PackStream List/Map/String used to pre-allocate a buffer sized directly from the untrusted length field, so a handful of crafted bytes claiming a huge length could drive a large-allocation denial-of-service before a single credential was checked. The decoders now bound every pre-allocation by the remaining input size, so a claimed length can never allocate more than the bytes actually available — a malformed/truncated frame errors cleanly instead of pinning memory. This class of decoder is also under continuous fuzzing (cargo +nightly fuzz run packstream_unpack / bolt_request, see the repository’s patinadb-raft/fuzz) to catch regressions before they ship.

Trust domains

A node exposes several surfaces with different trust expectations. Treat them as distinct and firewall accordingly:

SurfacePortAuthTrust domain
Client REST (/cypher, /mgmt/*)--addrBasic → per-user RBACapplication / operators
Bolt--bolt-addrLOGON → per-user RBACdrivers / Neo4j Browser
Peer RPC (/raft/*)--addrcluster secretother cluster nodes only
/metrics--addrBasic by default (open with --insecure-open-metrics)monitoring stack
/health, /version--addropenload balancers / probes

The peer-RPC surface shares the port with the client REST surface but is a cluster-internal trust domain — it should only be reachable from the other nodes, never the public internet. The cluster secret is the boundary; rotate it independently of the admin password. Enable HTTP-plane TLS (--tls-cert / --tls-key / --tls-ca) so peer RPCs and client traffic are encrypted and peers authenticate each other’s certificates.

RBAC changes propagate through Raft (eventual on followers)

User and grant changes (CREATE/ALTER/DROP USER, GRANT/REVOKE) are replicated log commands, not local edits: the leader hashes the password (argon2) and proposes the record, and every node applies it on commit. Two consequences:

  • A change is durable and cluster-wide once committed, but a follower only reflects it after it applies that log entry — a just-created user may be briefly unknown on a replica that hasn’t caught up. Authenticate writes and admin against the leader (or use a linearizable read) if you need read-your-own-grant immediately.
  • The root admin (--auth-user / --auth-password) is not replicated — it’s local config accepted directly on every node, so you can always authenticate even before the user directory has replicated (and you can’t lock yourself out of a node by dropping users).

Cypher-driven file I/O (LOAD CSV / export procs)

Two Cypher features touch the server’s local filesystem: LOAD CSV FROM 'file://…' reads a host file, and the CSV export procedures (patinadb.export.csv / patinadb.export.query / apoc.export.csv.query) write one. On the server these are locked down by two independent layers — both must pass:

  1. Role: global Admin. File I/O is authorized by effect, not by whether the query mutates the graph. A LOAD CSV (a read) and the export procs (declared ProcMode::Read) both require the global admin role — a per-database Reader or Writer is refused (403 on REST, a Bolt failure). Graph-only procedures (algorithms, statistics) are unaffected and stay Reader-level.

  2. Path sandbox (deny-by-default). The requested path must canonicalize to a location strictly under a configured allow-directory:

    • --allow-csv-dir <dir> — permitted directories for LOAD CSV reads (mirrors Neo4j’s dbms.directories.import).
    • --allow-export-dir <dir> — permitted directories for export writes.

    Both are repeatable and default-deny: with none configured the server refuses all Cypher file I/O. Paths are canonicalized before the check, so .. traversal and symlinks that escape the allow-directory are rejected. TOCTOU-hardened open (Linux): the canonicalize check and the actual file open are two separate syscalls, which in principle leaves a symlink-swap window between them. On Linux, the sandbox closes it by re-verifying the already-open file descriptor’s real path (via /proc/self/fd/N) is still under an allow-directory before any bytes are read or written — a race that swaps a symlink after the initial check is caught and the operation aborts before data crosses the boundary. (On other platforms the check falls back to canonicalize-then-open; a full openat2(RESOLVE_BENEATH) is a documented follow-up.)

Example: allow reads from /srv/import and writes to /srv/export only:

patinadb-raft --id 1 --addr 127.0.0.1:21001 --db /var/lib/patinadb --bootstrap \
  --auth-password "$PW" \
  --allow-csv-dir /srv/import \
  --allow-export-dir /srv/export

Then, authenticated as an admin:

LOAD CSV WITH HEADERS FROM 'file:///srv/import/people.csv' AS row
CREATE (:Person {name: row.name});

CALL patinadb.export.query('MATCH (n:Person) RETURN n.name AS name',
                           '/srv/export/people.csv');

A path outside those directories (e.g. file:///etc/passwd, or /srv/export/../../etc/cron.d/x) is denied by the sandbox, and a non-admin is denied by the role gate before the query runs — no file is touched either way.

Deployment checklist

  • Set a strong --auth-password (via env var, not a flag in shell history).
  • Set a distinct --cluster-secret (env var) shared by every node.
  • Bind --addr / --bolt-addr to localhost or a private interface; expose only through the TLS proxy.
  • Restrict the peer-RPC / /metrics surfaces to the cluster + monitoring network. /metrics is authenticated by default; only pass --insecure-open-metrics when it’s confined to a trusted monitoring network.
  • Enable TLS (--tls-cert/--tls-key/--tls-ca), or terminate TLS at a reverse proxy, for any production deployment — including a single node: with auth enabled and no TLS, REST/Bolt credentials cross the wire in cleartext (the node warns about this at startup). A multi-node cluster additionally needs it for the cluster secret + peer RPCs (a separate startup warning).
  • Same --auth-password and --cluster-secret on every cluster node.
  • --advertised-addr = the public endpoint when using routing behind a proxy.
  • Back up the --db directory (it holds the graph, the Raft log, and snapshots).
  • Leave --allow-csv-dir / --allow-export-dir unset unless you need LOAD CSV / export procs — file I/O is deny-by-default and requires the global admin role. When you do set them, point at dedicated, isolated directories (never a path holding secrets, config, or the --db dir).

Production Deployment (3-Node Bolt Cluster)

This is an operator walkthrough for one specific, common deployment: a 3-node patinadb-raft Raft cluster with automatic failover, fronting a single trusted backend service that connects over Bolt with a Neo4j driver, and using graph CRUD + Cypher, versioning / time-travel (AS OF, engrams, diffs, tags) and search (full-text + vector indexes).

The threat model is a trusted, single-tenant backend — the only client is your own application, not arbitrary internet users. So this is a deploy + secure

  • operate guide, not an untrusted-input hardening guide.

It ties together the reference chapters — High Availability, Authentication & TLS, Day-2 Operations, Editions & Limits, Configuration, Bolt, REST API and Time Travel — into one go-live sequence. Read those for depth; this chapter is the checklist.


1. Prerequisites & licensing

A production 3-node cluster requires a commercial license. Read this before anything else — it changes how you bring the cluster up.

The Community edition (the default with no valid license) caps max_voters at 1 (Editions & Limits). That has two concrete consequences for a 3-node cluster:

  • POST /mgmt/change-membership refuses any voter set larger than 1 with 409 VoterCapExceeded. That endpoint is the documented way to grow a --bootstrap node into a 3-voter cluster and to replace a dead voter later (evict-voteradd-learnerchange-membership) — a routine Day-2 op. In Community mode both are blocked.
  • Community mode also enforces mandatory telemetry: if the node cannot phone home within the grace window (default 72 h), it degrades to read-only (writes return 503, reads keep working). --disable-telemetry is refused at startup without a valid license (fail-closed), so an isolated / air-gapped deployment cannot opt out. See Licensing & Telemetry.
  • Community additionally caps ~5,000,000 nodes + edges (writes degrade to read-only past the cap) and 30-day rolling history retention (older time-travel history is squashed away — see §8).

A valid license lifts every one of these: max_voters becomes unlimited (or your tier’s cap), telemetry becomes best-effort and disableable, the scale cap is raised or removed, and retention defaults to unlimited. Install a license on every node before forming the cluster — see Installing a license and Getting a license. Precedence: --license / PATINADB_LICENSE (a file path or an inline token) → <db-root>/license.key.

Confirm the tier on each node after boot with the auth-exempt GET /version:

curl -s http://<node>:21001/version | jq '{tier, entitlements, usage}'
# tier must read "licensed" — "community" means the node found no valid license.

Honest nuance. You can technically stand up a fixed 3-voter set in one shot with a single POST /mgmt/init {"1":addr1,"2":addr2,"3":addr3} — that path calls Raft initialize directly and does not consult the voter cap. But you would then be unable to change-membership (add or replace a voter) without a license, and remain subject to the telemetry / scale / retention caps. It is not a viable production configuration — get a license.

Sizing

ResourceGuidance
Voters3 — tolerates one node failing. Use an odd count; see Cluster sizing.
CPUThe whole executor + Bolt driver + Raft apply are blocking work on a spawn_blocking pool. Give each node several cores; heavy analytics (CALL patinadb.algo.*) are per-core.
RAMEnough for the OS page cache over the working set plus the RFC-0007 cache pool. The server enables the cache stack by default at 40 % of the memory limit (Cache memory budget); tune PATINADB_CACHE_LIMIT / PATINADB_MEMORY_LIMIT.
DiskFast NVMe. patinaDB uses a disk-backed B-tree, so reads fault in only the pages they touch — fast local storage directly lowers read latency. See below.

Disk sizing

Each node stores the whole graph (every voter has a full replica), its Raft log, periodic snapshots, and the engram history (the versioning timeline). With unlimited retention (the licensed default) the engram history and snapshots grow without bound over the deployment’s lifetime — budget for it, and read §8 to decide between unlimited history and a bounded retention window. Back up the entire --db directory as a unit (graph, Raft log, snapshots, engram log); each database is a subdirectory of --db (On-disk layout).

Monitor free space with the patinadb_data_dir_available_bytes gauge and the shipped PatinaDBDiskSpaceLow / PatinaDBDiskSpaceCritical alerts (§7).


2. Bring up the 3-node cluster

A cluster forms by bootstrapping node 1, joining nodes 2 and 3 as learners, then promoting all three to voters. The promotion (change-membership) is the step that requires a license (§1).

With Docker Compose

The repository ships a 3-node stack at deploy/docker-compose.yml (plus Prometheus + Grafana). Treat it as a frictionless local demo, not a production template: it runs --insecure-disable-auth with no license, and its cluster-init step promotes to a 3-voter set via change-membership [1,2,3] — which an unlicensed node refuses with 409, so a plain unlicensed docker compose up leaves node 1 leading with nodes 2 and 3 as learners, not a 3-voter HA set. For production, harden it: add a license, turn auth on, and mount secrets as files.

# production 3-node compose (adapted from deploy/docker-compose.yml).
name: patinadb-prod

x-node: &node
  image: your-registry/patinadb-raft:<version>   # build from the repo Dockerfile
  restart: unless-stopped
  networks: [cl]
  environment:
    # Secrets mounted as files (see §3 "Secrets"); never bake them into the image.
    PATINADB_AUTH_PASSWORD_FILE: /run/secrets/auth_password
    PATINADB_CLUSTER_SECRET_FILE: /run/secrets/cluster_secret
    PATINADB_LICENSE_FILE: /run/secrets/license   # REQUIRED for a 3-voter cluster
  healthcheck:
    # distroless has no shell/curl; the binary is its own probe.
    test: ["CMD", "/usr/local/bin/patinadb-raft", "--healthcheck", "http://127.0.0.1:21001/ready"]
    interval: 30s
    timeout: 5s
    start_period: 20s
    retries: 3

services:
  node1:
    <<: *node
    container_name: patinadb-node1
    command:
      - --id=1
      - --addr=patinadb-node1:21001
      - --bolt-addr=0.0.0.0:7687
      - --advertised-addr=patinadb-node1.internal:7687   # a reachable host:port
      - --db=/data
      - --bootstrap
    ports: ["21001:21001", "7687:7687"]
    volumes: ["node1-data:/data", "./secrets:/run/secrets:ro"]

  node2:
    <<: *node
    container_name: patinadb-node2
    command:
      - --id=2
      - --addr=patinadb-node2:21001
      - --bolt-addr=0.0.0.0:7687
      - --advertised-addr=patinadb-node2.internal:7687
      - --db=/data
      - --join=patinadb-node1:21001
    ports: ["21002:21001", "7688:7687"]
    volumes: ["node2-data:/data", "./secrets:/run/secrets:ro"]

  node3:
    <<: *node
    container_name: patinadb-node3
    command:
      - --id=3
      - --addr=patinadb-node3:21001
      - --bolt-addr=0.0.0.0:7687
      - --advertised-addr=patinadb-node3.internal:7687
      - --db=/data
      - --join=patinadb-node1:21001
    ports: ["21003:21001", "7689:7687"]
    volumes: ["node3-data:/data", "./secrets:/run/secrets:ro"]

networks: { cl: { driver: bridge } }
volumes: { node1-data: , node2-data: , node3-data: }

--join registers nodes 2 and 3 as learners automatically. Once all three report their peer versions, promote them to voters against the leader (substitute your password):

curl -u neo4j:"$PW" -X POST http://patinadb-node1:21001/mgmt/change-membership \
  -H 'content-type: application/json' -d '[1,2,3]'
# → {"ok": true}   (a 409 here means Community mode / no license — see §1)

Notes. The demo --advertised-addr values are 127.0.0.1:7687/7688/7689 because everything runs on one host. On a real multi-host cluster set --advertised-addr to each node’s reachable host:port so neo4j:// routing sends drivers somewhere they can connect (§4). The distroless image runs as uid 65532; a fresh bind-mounted host volume is root-owned — chown -R 65532:65532 it, use a Docker named volume, or set the Kubernetes securityContext.fsGroup: 65532.

Without Docker (bare processes / systemd)

Equivalent to the compose above, one process per host:

# Node 1 — bootstrap leader
patinadb-raft --id 1 --addr node1.internal:21001 --db /var/lib/patinadb --bootstrap \
  --bolt-addr 0.0.0.0:7687 --advertised-addr node1.internal:7687 \
  --license /etc/patinadb/license.key
  # PATINADB_AUTH_PASSWORD, PATINADB_CLUSTER_SECRET set in the unit's environment

# Nodes 2 and 3 — auto-join as learners
patinadb-raft --id 2 --addr node2.internal:21001 --db /var/lib/patinadb --join node1.internal:21001 \
  --bolt-addr 0.0.0.0:7687 --advertised-addr node2.internal:7687 --license /etc/patinadb/license.key
patinadb-raft --id 3 --addr node3.internal:21001 --db /var/lib/patinadb --join node1.internal:21001 \
  --bolt-addr 0.0.0.0:7687 --advertised-addr node3.internal:7687 --license /etc/patinadb/license.key

Then promote to a 3-voter set (as above). If you prefer to skip the bootstrap → learner → promote sequence, a single POST /mgmt/init forms the set in one shot — but re-read the §1 nuance about the voter cap:

curl -u neo4j:"$PW" -X POST http://node1.internal:21001/mgmt/init \
  -H 'content-type: application/json' \
  -d '{"1":"node1.internal:21001","2":"node2.internal:21001","3":"node3.internal:21001"}'

Verify the cluster formed

# One leader + three voters, from any node:
curl -u neo4j:"$PW" -s http://node1.internal:21001/mgmt/cluster | jq

# Every node is ready (auth-exempt):
for n in node1 node2 node3; do curl -s http://$n.internal:21001/ready | jq -c; done

For growing/shrinking membership, failover behaviour, and rolling upgrades see High Availability.


3. Secure it

The default posture is fail-closed: an empty --auth-password refuses to start unless you also pass --insecure-disable-auth (Authentication). Work through the security deployment checklist; the essentials for this profile:

  • Auth. Set a strong --auth-password (the root-admin credential; REST Basic
    • Bolt LOGON). Your backend authenticates over Bolt at LOGON. For least privilege, create a dedicated user with only the roles it needs rather than logging in as neo4j (Users & roles).
  • TLS. Native --tls-cert / --tls-key (PEM) secures the HTTP plane (REST + management + peer RPC) and the Bolt endpoint (bolt+s:// / wss://) with the same certificate — connect the driver with the +s scheme. It is opt-in; without it everything is plaintext, so a plaintext node logs a loud cleartext-credentials warning at startup. If you terminate TLS at a reverse proxy instead, set --advertised-addr to the public endpoint. See Native TLS for the HTTP plane and Reverse-proxy termination.

Internal cluster traffic (cluster secret + peer TLS)

  • Cluster secret. Peer /raft/* RPCs authenticate with a dedicated --cluster-secret (PATINADB_CLUSTER_SECRET) carried in the X-Cluster-Secret header. All nodes must share the same value. If left empty it falls back to --auth-password, but a dedicated secret lets you rotate the two independently.
  • Peer TLS. For a self-signed / private-CA cluster, give every node --tls-ca <ca.pem> so peers verify each other; the cert’s SANs must cover the peer --addr hosts.

Secrets (files & managers)

Do not pass secrets as plain env vars (they leak into docker inspect / /proc/<pid>/environ). Use the file or command conventions (Secrets management) for PATINADB_AUTH_PASSWORD, PATINADB_CLUSTER_SECRET, and PATINADB_LICENSE:

  • <VAR>_FILE — mount the secret as a file: PATINADB_AUTH_PASSWORD_FILE=/run/secrets/auth_password.
  • <VAR>_COMMAND — fetch from a secrets manager: PATINADB_AUTH_PASSWORD_COMMAND="vault kv get -field=pw secret/patinadb".

Precedence: direct env/flag > <VAR>_FILE > <VAR>_COMMAND > config file > default. All three are fail-closed — an unreadable file or a failing command is a loud startup error, never a silent “no secret”.

Online rotation

Rotate without a full restart (admin-only, per node): stage a new cluster secret on every node with POST /mgmt/rotate-cluster-secret {"secret":…} (both accepted during the grace window), then POST /mgmt/retire-cluster-secret on every node; rotate the root password with POST /mgmt/rotate-admin-password {"password":…}.

Bind & expose deliberately

Bind --addr and --bolt-addr to the private interfaces the backend and peers reach — not 0.0.0.0 on a public interface. /metrics is authenticated by default (its db=<name> labels leak database names); keep it that way, or open it only on a private monitoring network with --insecure-open-metrics. /health, /ready and /version are always open probes.

Multi-tenant note (usually N/A here). This profile is single-tenant, so the default open RBAC is fine. If you later host multiple isolated tenants, enable --rbac-closed (closed-mode tenant isolation).


4. Connect the backend

Point your Neo4j driver at the cluster. Prefer the routing scheme so reads spread across followers and writes go to the leader automatically (Cluster-aware routing):

neo4j://<any-node-host>:7687      # or neo4j+s:// with TLS
  • neo4j:// vs bolt://. neo4j:// asks the cluster for a routing table and load-balances: writes → leader, reads → followers/learners, with automatic reconnection on failover. bolt:// pins to one node (no routing). Use neo4j:// (or neo4j+s:// with TLS). Routing hands drivers each node’s --advertised-addr, so those must be reachable from the backend.
  • Read consistency. A default read serves the node’s local applied state (causally consistent within a database, eventually consistent across replicas — a follower may lag). The Bolt path always reads locally; the "consistency":"linearizable" opt-in is REST-only and leader-only. When you need a lag-immune read that any follower can serve, read AS OF TAG '<name>' (a replicated, deterministic point — the “clean-tag” pattern in Read consistency).
  • Transactions are snapshot isolation, not serializable. BEGIN … COMMIT gets repeatable reads and first-committer-wins on write-write conflicts (a conflict raises the driver-retryable Neo.TransientError.Transaction.LockClientStoppedwrap writes in a retry loop). SI does not prevent write skew; for an invariant spanning rows one tx reads and another writes, use a single autocommit statement or an app-level guard. See Transactions.
  • Pool & limits. Size the driver’s connection pool below the server’s --max-bolt-connections (default 1024) — a cap on authenticated connections; an unauthenticated socket (silent, stalled mid-handshake, or idling after HELLO) never spends this budget (issue #440), instead bounded in time by --bolt-handshake-timeout-secs (default 10) and --bolt-idle-timeout-secs (default 300). Tune the tx guards for your workload: --max-tx-ops (default 5,000,000 buffered ops per explicit tx) and --idle-tx-timeout-secs (default 300 — an abandoned BEGIN is rolled back and its connection closed). A client that RUNs a streaming read then never PULLs is reaped after --stream-pull-timeout-secs (default 300). See Connection resource limits and Transaction resource limits.

Concurrency & retries

If your backend runs multiple replicas, read this first. A horizontally scaled backend — say 3 app replicas behind a load balancer — means two replicas can issue a write to the same node or edge at the same time. That is a supported, everyday topology, and patinaDB does not lose an update or create a duplicate under it: the server detects the collision and lets exactly one writer through. But it handles the collision by rejecting the other writer with a retryable error, not by making it wait — so the one thing your backend MUST do is retry that error, on every replica, for every write. The mechanism below is how, and it is a requirement for a multi-writer deployment, not a recommendation. A backend that treats the retryable conflict as a hard failure will see write failures under contention (never lost or corrupted data — but avoidable failures). This is verified end-to-end: 16 concurrent clients hammering one counter over autocommit land every increment exactly once, and two replicas creating the same UNIQUE-constrained key collide so that exactly one succeeds (pipelined_bolt_autocommit_contended_counter_never_loses_an_update, bolt_occ_duplicate_unique_create_conflicts).

You do not lock the database. patinaDB has no pessimistic-lock primitive (no SELECT … FOR UPDATE) and does not need one. Concurrency control is optimistic and works out of the box:

  • Ordinary write–write conflicts (two requests updating the same node/edge) are detected at COMMIT: the first committer wins, the second gets the driver-retryable Neo.TransientError.Transaction.LockClientStopped. Use your driver’s managed transactionssession.execute_write(tx_fn) (Python) / session.executeWrite(...) (JS/Java) — which retry a transient automatically. That is the whole mechanism: concurrent updates to the same data are serialized and retried with no locking and no lost updates.

    # REQUIRED for a multi-writer backend: the driver retries the transient for you.
    def bump(tx, node_id):
        tx.run("MATCH (n {id:$id}) SET n.count = n.count + 1", id=node_id)
    with driver.session() as s:
        s.execute_write(bump, node_id)   # auto-retries LockClientStopped
    
    # WRONG for a multi-writer backend: a naked autocommit run treats the
    # conflict as a hard error. No data is lost or corrupted, but under
    # contention between replicas this call *fails* instead of retrying.
    # with driver.session() as s:
    #     s.run("MATCH (n {id:$id}) SET n.count = n.count + 1", id=node_id)
    

    The same holds for MERGE on a UNIQUE-constrained key — the classic “upsert this user from whichever replica saw the request first”. Two replicas running MERGE (u:User {id:$id}) … concurrently collide on the value, one gets the retryable transient, and a managed transaction converges it to a single node. Wrap it in execute_write and it is safe across any number of replicas; run it naked and a simultaneous upsert can fail.

  • Write skew is the only case OCC does not catch (snapshot isolation is not serializable): two transactions each read a value and write disjoint rows based on it, so there is no write–write overlap to detect. This matters only for a multi-row invariant that genuinely requires serializability (e.g. “the sum of two balances must stay ≥ 0”, debited by two txns on different rows). The fix is not a lock — force the skew into a detectable write–write conflict by having both transactions write a shared sentinel/version node:

    // Both txns touch the same guard row → OCC now detects the conflict → retry.
    MATCH (g:AccountGroup {id:$gid}) SET g.version = g.version + 1
    

    If your backend has no such cross-row invariant (the common case), you need none of this — managed transactions are sufficient.

What makes this work: pipelined writes (PATINADB_PIPELINED_WRITES, default on). Concurrent autocommit writes to one database resolve in parallel and are admitted under the optimistic check above, rather than serializing behind each other’s whole Raft round-trip — which is why a conflict between two concurrent writers surfaces as the retryable transient instead of being avoided by making everyone queue. Both explicit transactions and plain autocommit statements go through it, so the retry advice above applies to both. PATINADB_PIPELINED_WRITES=0 is the rollback valve: writes then serialize per database — no lost updates either way, but ~4–5× less write throughput on a disjoint-write workload, and note this does not buy serializability (Bolt’s explicit transactions were already snapshot-isolated). See Configuration.


5. Verify (go-live smoke checklist)

Run these against the cluster before cutting traffic over. Substitute your Bolt URL / credentials; the examples use Cypher you can run from the driver or via POST /cypher.

  1. Validate the config first — before the node even boots. On each node, run the exact command line / env / config file you deploy with, plus --check-config. It resolves everything the way a real boot does, prints what it understood, and exits non-zero if a setting is invalid or a runtime env knob is set to a value the engine would silently ignore (e.g. a PATINADB_MAX_AGG_ROWS=5M that the runtime wants as 5000000). This catches the misconfiguration class that otherwise only shows up as surprising behaviour in production (Config dry run):

    patinadb-raft --config node.yaml --check-config || { echo "bad config"; exit 1; }
    

    Confirm the printed cache budget shows the caches ENABLED with the memory you intend — a typo’d --cache-limit silently disabling the cache is exactly what this step exists to catch.

  2. Write + read a node (exercise the write path through Raft):

    CREATE (:HealthCheck {id: 'smoke-1', at: datetime()});
    MATCH (h:HealthCheck {id: 'smoke-1'}) RETURN h.at;
    
  3. Time-travel read (AS OF). Capture the current engram, mutate, then read the past state (Time Travel):

    // note HEAD first
    CALL patinadb.engrams() YIELD id RETURN id ORDER BY id DESC LIMIT 1;
    // ... then, after a later write, read the earlier state:
    USE default AS OF '<engram-id>' MATCH (h:HealthCheck) RETURN count(h);
    
  4. Full-text search (Full-Text Search):

    CREATE FULLTEXT INDEX docs FOR (n:Doc) ON EACH [n.body];
    CREATE (:Doc {body: 'patinadb production go-live'});
    CALL db.index.fulltext.queryNodes('docs', 'production') YIELD node, score RETURN node.body, score;
    
  5. Vector search (Vector Search) — note the arg order queryNodes(indexName, k, queryVector):

    CREATE VECTOR INDEX emb FOR (n:Item) ON (n.vec)
      OPTIONS { indexConfig: { `vector.dimensions`: 3, `vector.similarity_function`: 'cosine' } };
    CREATE (:Item {vec: [0.1, 0.2, 0.3]});
    CALL db.index.vector.queryNodes('emb', 5, [0.1, 0.2, 0.3]) YIELD node, score RETURN node, score;
    
  6. Cross-node consistency. After a write on the leader, confirm every node converges (each node serves its own applied state):

    for n in node1 node2 node3; do
      curl -u neo4j:"$PW" -s -X POST http://$n.internal:21001/cypher \
        -H 'content-type: application/json' \
        -d '{"query":"MATCH (h:HealthCheck {id:'\''smoke-1'\''}) RETURN count(h) AS c"}' | jq -c
    done   # every node must report the same count once replication settles
    
  7. Failover drill. Kill the leader (docker stop patinadb-node1 or SIGTERM), confirm a survivor becomes leader (GET /mgmt/cluster), confirm writes resume, and restart the node — it rejoins and catches up. No acknowledged write is lost across a clean failover (Failover behaviour).

  8. Multi-writer retryonly if your backend runs more than one replica. This does not test patinaDB; it tests your integration code, and it is the most likely place a multi-replica backend is subtly wrong. From your app (not curl), fire the same SET n.count = n.count + 1 on one node from several concurrent workers, each running the whole write through a managed transaction (execute_write / executeWrite), and assert the final count equals the number of writes. If it matches, your retry path works. If it comes up short — or you see Neo.TransientError.Transaction.LockClientStopped surfacing as an error to your caller — your writes are not wrapped in a managed transaction; fix that before go-live (see Concurrency & retries). The count is never wrong on the DB side; a shortfall means a conflict was dropped instead of retried.

Delete the :HealthCheck / :Doc / :Item smoke data afterward.


6. Backup & disaster recovery

The DR primitives are admin-only /mgmt/* REST endpoints, run against the leader (REST API). A backup is a portable, streamed dump of the whole registry (graph + full-text + vector + constraints + point indexes + tags + RBAC users).

Take a backup (with time-travel history)

# Whole-cluster portable backup INCLUDING the engram timeline (PITR).
# ?history=true is a licensed (Pro/Enterprise) feature — 403 in Community.
curl -u neo4j:"$PW" 'http://<leader>:21001/mgmt/snapshot?history=true' -o patinadb-backup.json

Without ?history=true the backup is a HEAD-state dump (no AS OF over pre-backup history). Schedule this against the leader; a follower returns 503 naming the leader (the endpoint is leader-anchored/linearizable).

Restore

# Restore into a cluster (leader). A non-empty target is refused (409) without ?force=true.
curl -u neo4j:"$PW" -X POST 'http://<leader>:21001/mgmt/restore?force=true' \
  -H 'content-type: application/json' --data-binary @patinadb-backup.json

Then verify with the shipped scripts/verify-restore.sh (readiness + node/edge counts + an optional AS OF probe), or re-run the §5 checklist.

Portable CSV export. GET /mgmt/export?db=<name>&format=csv|parquet|arrow streams one database as a tar of neo4j-admin-style files for an external pipeline (REST API).

Recovery scenarios

  • A node is lost. Bring up a replacement with the same --id and an empty --db, --join the cluster; it bootstraps from a streamed snapshot and catches up automatically — no manual restore. If the old voter is permanently gone, evict-voter it, add the replacement as a learner, and change-membership it in (licensed). See Removing a dead voter.
  • The leader dies. Survivors auto-elect a new leader (election timeout 750–1500 ms); committed writes survive, in-flight uncommitted writes to the dead leader are retried by the driver. This is the automatic-failover guarantee the failover suite exercises.
  • Logical corruption (a bad write). Restore from a ?history=true backup, or read the good state AS OF a pre-incident engram/tag and promote it.

The repository’s docs/disaster-recovery.md carries the full RPO/RTO-per-failure runbook; ask your patinaDB contact for it if you don’t have the repo.


7. Monitoring & alerting

Every node exports Prometheus metrics at GET /metrics (auth-gated by default). The repo ships a ready stack — deploy/prometheus.yml, deploy/prometheus/alerts.yml, and Grafana dashboards under deploy/grafana/ — documented in deploy/README.md.

Probes (Kubernetes / load balancer)

ProbeMeaning
GET /healthLiveness200 whenever the process is up. Use for K8s livenessProbe.
GET /readyReadiness200 only when the node can actually serve. Use for K8s readinessProbe and to gate a load balancer / neo4j:// read rotation.

Both are auth-exempt and never shed by the concurrency limiter. /ready returns 503 with a reason (priority order): no_leader, installing, degraded (community write-degrade), lagging (apply lag over --readiness-max-lag, default 50), shedding (concurrency limiter saturated). See Health vs. readiness probes and the Kubernetes example.

Key metrics to alert on

The shipped deploy/prometheus/alerts.yml defines these groups/rules:

GroupRules (what they catch)
patinadb-clusterPatinaDBNodeDown, PatinaDBNoLeader (whole-cluster), PatinaDBNodeMissingLeader (per-node, issue #532), PatinaDBReplicationLagHigh/Critical (this node’s own apply backlog), PatinaDBFollowerReplicationLagHigh (leader’s view of a lagging follower, issue #532), PatinaDBApplyErrors
patinadb-diskPatinaDBDiskSpaceLow (<10 % free / 10 m), PatinaDBDiskSpaceCritical (<3 % or <1 GiB free / 5 m)
patinadb-queriesPatinaDBQueryErrorRateHigh/Critical, PatinaDBQueryLatencyP95High, PatinaDBHttpErrorRateHigh
patinadb-cachePatinaDBCacheMemoryPressure
patinadb-telemetryPatinaDBTelemetryDegradeImminent, PatinaDBTelemetryDegraded (community mode only)
patinadb-entitlementsPatinaDBEntitlementUsageHigh/Critical (approaching a license cap)

The metric series behind them include: patinadb_raft_is_leader, patinadb_raft_replication_lag (this node’s own apply-backlog-of-known-log — does NOT catch a partitioned-but-reachable node, see below), patinadb_raft_has_leader (per-node “do I know a leader right now”, issue #532), patinadb_raft_follower_lag{follower_id} (leader-only, the genuine distance-behind-the-leader signal), patinadb_raft_apply_errors_total, patinadb_data_dir_available_bytes / _used_bytes / _total_bytes (the ENOSPC early-warning signal), patinadb_query_duration_seconds / patinadb_queries_total, patinadb_http_requests_total, the patinadb_cache_* fill/hit/eviction series, patinadb_mem_available_bytes, and patinadb_entitlement_usage_ratio{axis} / _limit{axis}. Alertmanager is not wired by default — point the shipped rules at your own Alertmanager. The deploy/ Grafana dashboards are patinadb-cache.json and the cluster dashboard (cypherlite-cluster.json, title “patinaDB Cluster”). For the full field reference see the “Metrics reference” table in deploy/README.md.

Also useful operationally: GET /mgmt/cluster (live topology), GET /mgmt/queries (per-shape slow-query stats), GET /mgmt/dbsizes, GET /mgmt/cache, GET /mgmt/audit (security audit — node-local, in-memory). Enable request tracing / an OTLP collector with --otel-endpoint (Request correlation & tracing).


8. Retention & disk

Your profile uses versioning, so the history retention window is an explicit operator decision:

  • Unlimited retention (the licensed default). The engram history + snapshots keep the whole timeline — AS OF any past point works forever — but on-disk volume grows without bound over the deployment’s life. Budget disk accordingly and alert on patinadb_data_dir_available_bytes.
  • A bounded window (via a license that sets history_retention_days, or Community’s forced 30-day cap). A background best-effort task periodically squashes engrams older than the cutoff, reclaiming disk — but time-travel / AS OF / diff beyond the window is then unavailable. See History retention in practice.

Caveat — retention under sustained time-travel read load. The background squash and time-travel reads are coordinated so a squash-delete can never tear an in-flight historical read (correctness is preserved — you never get a wrong historical result). The trade-off is that under a continuous stream of overlapping time-travel reads, the squash writer can be starved / delayed — it waits for a quiescent gap before deleting. Correctness is always preserved (you never get a wrong or torn historical read); the trade-off is squash lag, and a periodic squash runs on the same serial state-machine apply path, so a prolonged starvation could delay apply under this pathological load. Tightening that coordination (a bounded wait / fairness so squash can’t be starved indefinitely) is a tracked hardening item — issue #499. Recommendation for this profile: prefer unlimited retention (the licensed default) and manage disk with capacity + backups rather than an aggressive squash window — that sidesteps the interaction entirely. Only enable a bounded window if your disk budget requires it and your time-travel read load is light, or after #499 is closed.

Time-travel reads in this profile

Your backend uses AS OF, so two operational facts matter more here than the defaults suggest:

  1. An unknown or squashed engram id reads as the empty graph, not an error. A backend that stores engram ids and reads them back later cannot distinguish “that point in history is gone” from “nothing matched”. Existence-check with CALL patinadb.engrams() before trusting an empty historical result, or — better for this profile — use tags for any point you intend to read again: a tag is pinned against squash and snapshotted, so it stays both reachable and cheap. See Time Travel. This is the single most likely time-travel surprise in a backend integration.
  2. The read-path knobs are safe to flip; the results never change. PATINADB_PARTIAL_TIMETRAVEL (default on) reconstructs only the subgraph a read touches; =0 is the rollback valve if you ever suspect it — every read then takes the full reconstruct, slower but byte-identical. PATINADB_INDEXED_SNAPSHOTS (default off) is worth enabling only if your backend re-reads a known set of historical points repeatedly; its sidecar is built lazily in the background and costs disk, so bound it with PATINADB_MAX_SNAPSHOT_INDEXES (default 16). Neither touches the write path. Full picture: Performance & tuning.

Loading the initial dataset

Seed the cluster before go-live rather than through a per-row Bolt write — a CREATE per row is the slowest way to fill a graph. For a large columnar file (or a coupled node+edge subgraph) already on the leader’s disk, use POST /mgmt/upsert/upsert-edges/upsert-graph (index-accelerated match-or-create, chunkable — see Bulk Upsert); for a streamed CSV, use LOAD CSV … CALL { … } IN TRANSACTIONS OF n ROWS (commits in bounded chunks instead of one giant transaction). Both, plus the throughput you should expect and the reason a single unbounded LOAD CSV … CREATE is a memory cliff, are in Bulk Loading & Import.

Every path above is versioned — the initial load is one changeset (or one per chunk) in the engram timeline, so it is a valid AS OF point right away. Take a tag right after the load (CREATE TAG initial-load) so there is a named point to read back to.


9. Known limitations for this profile

Read Limitations in full; the ones that bear on a 3-node Bolt cluster doing CRUD + versioning + search:

  • Bolt transactions are snapshot isolation, not serializable — write skew is possible; use single autocommit statements or app-level guards for cross-row invariants, and retry the transient conflict error (The server).
  • Bolt reads are always local (eventually consistent on followers). The linearizable read is REST-only; use the AS OF TAG clean-tag pattern for a lag-immune, follower-servable read.
  • RBAC enforcement is reject, not row-filtering, and per-property / relationship-type RBAC is not implemented (moot for a single trusted tenant).
  • Security audit log is node-local + in-memory (bounded ring, not Raft-replicated, not persisted across restart) — ship the patinadb::audit tracing target to a central sink if you need durable audit.
  • No encryption-at-rest — use OS-level disk encryption on the --db volume.
  • Full-text: string properties only; read-modify-write postings (not for very high write throughput); prefix/fuzzy capped. Vector: IVF-Flat ANN (Full-Text Search, Vector Search).
  • CREATE EDGE SORTED INDEX definitions are not carried in Raft snapshots — a node bootstrapped purely from a streamed snapshot falls back to traverse+sort for that shape until the DDL is re-issued (correctness unaffected).
  • On-disk format upgrades may require a dump/reload: export via GET /mgmt/snapshot, upgrade the binaries, import — plan this into your rolling upgrade (Storage & scale, Rolling upgrades).

The parser / DoS input-hardening caveats in the limitations chapter concern untrusted input and are not relevant to this single-trusted-backend profile.

Configuration Reference

A consolidated reference for the server’s knobs.

Server (patinadb-raft)

FlagDefaultNotes
--config <path>offLoad settings from a YAML config file (see below).
--print-config-schemaoffPrint a JSON Schema for the config file to stdout and exit.
--check-configoffValidate the resolved config, print what was understood, and exit without booting — an nginx -t-style dry run (see below).
--id <u64>requiredUnique Raft node id (here or in the config file).
--addr <host:port>requiredHTTP (REST + management + peer RPC).
--db <dir>requiredDatabase root (one subdir per database).
--bootstrapoffSelf-init a single-voter cluster.
--join <member>offAuto-join an existing member as a learner on startup. Excludes --bootstrap.
--bolt-addr <addr>127.0.0.1:7687Bolt listener; "" disables Bolt.
--advertised-addr <a>= --bolt-addrPublic Bolt address for routing behind a proxy.
--auth-user <name>neo4jAuth username.
--auth-password <p>""Shared password. Empty = no auth, fail-closed: the node won’t start without --insecure-disable-auth.
--insecure-disable-authoffExplicitly allow running open (empty password). Trusted networks only.
--tls-cert <path>offPEM cert chain. With --tls-key, serves the HTTP plane (REST + peer RPCs) over HTTPS.
--tls-key <path>offPEM private key (required with --tls-cert).
--tls-ca <path>system rootsPEM CA peers verify each other with (self-signed / private-CA clusters).
--require-metrics-authon (default)Gate GET /metrics behind Basic auth. Default-on since series carry db=<name> labels.
--insecure-open-metricsoffOpt out of metrics auth — serve /metrics open on a private monitoring network.
--query-timeout-secs <n>300Per-request budget for a REST /cypher read; overrun → 503 (deadline, not a hard cancel). 0 = off.
--max-concurrent-requests <n>512Cap on in-flight HTTP requests; excess shed with 503. 0 = unlimited.
--readiness-max-lag <n>50/ready apply-lag tolerance: report not ready (503 lagging) when last_log_index − last_applied exceeds this. See Day-2 operations.
--otel-endpoint <url> (PATINADB_OTEL_ENDPOINT)unsetEnable request/trace correlation (X-Request-Id + inbound W3C traceparent) and force structured JSON logs for OpenTelemetry-collector ingestion. See Day-2 operations.
--allow-csv-dir <dir>deny-allDirectory LOAD CSV FROM 'file://…' may read from. Repeatable. Unset ⇒ every LOAD CSV file read is refused. See Cypher file I/O.
--allow-export-dir <dir>deny-allDirectory the CSV export procs (patinadb.export.*) may write to. Repeatable. Unset ⇒ every export write is refused.
--rbac-closed (PATINADB_RBAC_CLOSED)offClosed-mode RBAC: deny a non-admin any database it holds no explicit grant on. See Authentication & TLS.
--telemetry-degrade-override-until <value> (PATINADB_TELEMETRY_DEGRADE_OVERRIDE)unsetTime-boxed break-glass: keep writes enabled past a missed telemetry grace window until an absolute deadline. See Day-2 operations.
--bookmark-wait-timeout-secs <n> (PATINADB_BOOKMARK_WAIT_TIMEOUT_SECS)10How long a Bolt BEGIN/RUN will block for this node to catch up to an incoming driver-session bookmark before failing with a retryable BookmarkTimeout. 0 disables the wait (bookmarks are parsed but ignored). See Bolt → Causal consistency.

Environment: PATINADB_AUTH_PASSWORD sets the auth password (preferred over a shell flag).

More server flags (RBAC, Bolt/transaction limits, telemetry, ops)

The table above covers the flags you’ll set on day one. These round out the full surface — most have safe defaults and only need touching for a specific requirement (multi-tenant RBAC, transaction-buffer bounds, dead-voter auto-repair, telemetry/licensing). Every one is also settable via PATINADB_* env var and/or the YAML config file, same precedence rules.

FlagDefaultNotes
--cluster-secret <s> (PATINADB_CLUSTER_SECRET)= --auth-passwordDedicated peer /raft/* secret, separate from the root-admin credential so the two rotate independently.
--rbac-closed (PATINADB_RBAC_CLOSED)offDeny a non-admin any database it holds no explicit grant on (tenant isolation). See Authentication & TLS.
--rbac-rel-grants (PATINADB_RBAC_REL_GRANTS)offStrict relationship-type RBAC: a label-scoped user must also hold a per-rel-type grant for every edge type traversed. See Authentication & TLS.
--rbac-rel-property-grants (PATINADB_RBAC_REL_PROPERTY_GRANTS)offStrict relationship-property RBAC: a label-scoped user must also hold a per-(relType, prop) grant. See Authentication & TLS.
--auth-max-attempts-per-min <n> (PATINADB_AUTH_MAX_ATTEMPTS_PER_MIN)0 (disabled)Brute-force auth throttle, per source IP and per username. Blocks a failed-login flood before it reaches the argon2 verify.
--max-reads-per-user <n> (PATINADB_MAX_READS_PER_USER)0 (unlimited)Cap concurrent reads per authenticated principal — one user’s burst of expensive read procs can’t exhaust the blocking pool.
--blocking-threads <n> (PATINADB_BLOCKING_THREADS)tokio default (512)Absolute ceiling on the tokio blocking-thread pool.
--max-bolt-connections <n>1024Cap on concurrent authenticated Bolt connections; 0 = unlimited. See the note below (issue #440) — an unauthenticated connection never spends this budget.
--bolt-handshake-timeout-secs <n> (PATINADB_BOLT_HANDSHAKE_TIMEOUT_SECS)10Close a Bolt socket that sends nothing (or stalls mid-handshake) before completing the version-negotiation handshake. 0 disables.
--bolt-idle-timeout-secs <n> (PATINADB_BOLT_IDLE_TIMEOUT_SECS)300Close a Bolt connection that sits with no open transaction and no started result stream and sends no message for this long (covers a connection that finishes HELLO/LOGON then goes silent). 0 disables.
--idle-tx-timeout-secs <n> (PATINADB_IDLE_TX_TIMEOUT_SECS)300Roll back + close an explicit Bolt transaction left idle this long (“idle in transaction” guard). 0 disables.
--max-tx-ops <n> (PATINADB_MAX_TX_OPS)5,000,000Max buffered DeltaOps an explicit Bolt transaction may accumulate before it’s aborted. 0 = unlimited.
--stream-pull-timeout-secs <n> (PATINADB_STREAM_PULL_TIMEOUT_SECS)300Cancel a streaming Bolt RUN result the client never PULLs within this window. 0 disables.
--audit-max-entries <n> (PATINADB_AUDIT_MAX_ENTRIES)100,000Retention for the durable node-local security-audit log. 0 = unlimited.
--auto-evict-after-secs <n> (PATINADB_AUTO_EVICT_AFTER_SECS)0 (disabled)Auto-evict a voter that stays dead (unreachable + not replicating) past this many seconds, using the same quorum-preserving guard as POST /mgmt/evict-voter. See Day-2 operations.
--license <path|token> (PATINADB_LICENSE)none (community)Signed license file or inline token. See Licensing & Telemetry.
--install-name <text> (PATINADB_INSTALL_NAME)unset (anonymous)Opt-in, operator-chosen telemetry label.
--telemetry-interval-secs <n>21600 (6h)Heartbeat interval.
--telemetry-grace-secs <n>259200 (72h)Community-mode grace window before write-degrade.
--disable-telemetryoffTurn off telemetry entirely — requires a valid license (fail-closed otherwise).

Also note PATINADB_MAX_CONCURRENT_RAFT_RPCS — a separate, env-var-only backpressure cap (default 1024, 0 = unlimited) on inbound peer /raft/* RPCs, distinct from --max-concurrent-requests (which governs client traffic) so a peer-RPC flood and a client-request flood can’t shed each other’s capacity.

YAML config file (--config)

Instead of (or alongside) flags, point the server at a YAML file mirroring the settings above:

# node.yaml
id: 1
addr: "127.0.0.1:21001"
db: "/var/lib/patinadb"
bootstrap: true
bolt_addr: "0.0.0.0:7687"
auth_user: "neo4j"
# auth_password: prefer the PATINADB_AUTH_PASSWORD env var over the file
query_timeout_secs: 30
max_concurrent_requests: 256
patinadb-raft --config node.yaml

Every field is optional; an absent key falls back to the CLI flag, then to the built-in default. The YAML key names match the long flag names with - replaced by _ (e.g. --bolt-addrbolt_addr). Unknown keys are rejected so a typo fails loudly.

Precedence (highest wins): an explicitly-passed CLI flag (or its bound env var, e.g. PATINADB_AUTH_PASSWORD) > the config file > the built-in default. A flag left at its clap default does not override a value set in the file — only flags the operator actually typed do. The required settings (id, addr, db) may come from either the file or flags; if neither supplies one, startup fails with a clear error.

Config JSON Schema (--print-config-schema)

patinadb-raft --print-config-schema prints a JSON Schema (Draft 7) for the config file — every property carries its description (lifted from the Rust doc-comments) so editors can offer autocompletion and validation. It works without any other arguments:

patinadb-raft --print-config-schema > patinadb-config.schema.json

Config dry run (--check-config)

patinadb-raft --check-config is an nginx -t for patinaDB: it resolves the effective configuration exactly the way a real boot does — CLI/env > <VAR>_FILE/_COMMAND > --config file > default — prints what it understood, validates it, and exits without starting the node. Exit code 0 means valid, 1 means a setting is invalid or a runtime env knob is set to a value the engine would silently ignore. Drop it into a deploy script or CI gate:

patinadb-raft --id 1 --addr 127.0.0.1:21001 --db /var/lib/patinadb \
  --bootstrap --auth-password "$PW" --cache-limit 40% --check-config || exit 1

It prints the resolved node identity, cluster mode, security posture (flagging auth-on-without-TLS), every limit with its effective value, the resolved cache budget in bytes (so you can see the caches are actually enabled and with how much), the telemetry/licensing state, and the filesystem sandbox.

Its most valuable job is the runtime env-knob scan. Several engine knobs (PATINADB_MAX_AGG_ROWS, PATINADB_CACHE_LIMIT, …) are read lazily deep in the engine and fall back to their default if the value doesn’t parse — silently. So PATINADB_MAX_AGG_ROWS=5M looks like five million but is ignored (the runtime wants a plain integer, 5000000), and — the case that motivated this feature — a PATINADB_CACHE_LIMIT typo could silently leave the cache off. --check-config resolves each set knob with the same parser the runtime uses and fails the check on any set-but-unparseable value, naming it:

Runtime env knobs (curated — set values only)
  PATINADB_CACHE_LIMIT               512MiB → 536870912 bytes
  PATINADB_MAX_AGG_ROWS              5M ✗ expected a plain integer, got '5M'

configuration INVALID — 1 problem(s):
  ✗ PATINADB_MAX_AGG_ROWS is set to '5M' but expected a plain integer … SILENTLY IGNORED

Secrets management

The three sensitive server settings — PATINADB_AUTH_PASSWORD (root-admin credential), PATINADB_CLUSTER_SECRET (peer /raft/* shared secret), and PATINADB_LICENSE — can be sourced without ever putting the plaintext value in a -e env var or a config file. patinaDB embeds no secrets-manager SDK; two conventions integrate one while keeping its client out of the binary (the server only ever sees the resolved value at startup):

  • <VAR>_FILE — names a file whose contents are the real value (mirrors POSTGRES_PASSWORD_FILE). Point it at a Docker Compose secrets: file, a Kubernetes Secret volume, or a tmpfs file a Vault Agent / AWS or GCP Secrets Store CSI-driver sidecar keeps refreshed — the DB reads the file, the sidecar owns the fetch + rotation. This is the pattern to prefer when you need auto-rotation. A trailing newline is trimmed.

  • <VAR>_COMMAND — names a shell command whose stdout (trailing newline trimmed) is the value, for a direct dynamic fetch with no sidecar:

    PATINADB_AUTH_PASSWORD_COMMAND="vault kv get -field=pw secret/patinadb"
    # AWS: "aws secretsmanager get-secret-value --secret-id patinadb/pw \
    #        --query SecretString --output text"
    # GCP: "gcloud secrets versions access latest --secret=patinadb-pw"
    

    The command runs via sh -c "<cmd>" once at startup (the fetch CLI must be present + authenticated). Fail-closed: a command that can’t spawn, exits non-zero, or produces empty output aborts startup with a loud error — it never silently falls back to “no secret” (which would look like --insecure-disable-auth). The fetched value is never logged.

Precedence (most specific wins): direct flag/env > <VAR>_FILE > <VAR>_COMMAND > config file > built-in default. Both _FILE and _COMMAND are only consulted when the direct flag/env is unset. See deploy/README.md for the sidecar + command examples.

Resource limits & quotas

Guards that bound the cost of a single query so one statement can’t exhaust memory or run unbounded. The engine budgets are environment variables; the two server request limits are flags (see the patinadb-raft table above).

LimitWhereDefaultPurpose
PATINADB_MAX_HOPSenv1000Depth cap for an unbounded variable-length hop ([*], or [*..n] with n unset). An explicit [*a..b] in the query always wins. Prevents runaway traversal on a cyclic graph.
PATINADB_CARTESIAN_CAPenv10000Max rows a disjoint (cross-product) multi-MATCH may produce before the query errors. Stops an accidental N×M blow-up.
PATINADB_MAX_AGG_ROWSenv5000000Cap on the O(input)/O(result) row buffers behind GROUP BY/aggregate, a full (non-top-K) ORDER BY, UNION dedup, and a hash-join build side. A clear error over the cap instead of a silent OOM. See Query Planning.
PATINADB_MAX_CAPTURE_OPSenv5000000Cap on the number of resolved ops a single write statement (the Raft leader’s resolve step) may buffer before recording one engram/Raft entry. A whole-graph SET/bulk CREATE over the cap fails with a clear error pointing at CALL {…} IN TRANSACTIONS instead of risking an OOM or a giant single Raft entry.
PATINADB_MAX_ALGO_WORKenv1e9Static work-budget backstop for O(V·E) read procedures (betweenness/closeness): refuses a call whose estimated n·(n+e) exceeds this, so a Reader can’t trigger unbounded compute even without --query-timeout-secs. Admits realistic analytic graphs (10k nodes / 50k edges ≈ 6e8); raise it for a genuinely large centrality run. See Day-2 operations.
PATINADB_MAX_SNAPSHOTSenvunlimited (0/unset)Prunes on-disk periodic time-travel snapshot files down to the N most recent (+ every pinned/tagged one) after each snapshot-taking commit. A long-lived, write-heavy database otherwise grows snapshot files unbounded; pruning only slows reconstruction of an old, out-of-window engram — every AS OF result stays byte-identical.

Write pipelining

KnobDefaultPurpose
PATINADB_PIPELINED_WRITESonLets concurrent autocommit writes to one database resolve in parallel instead of serializing behind each other’s whole Raft round-trip, admitting each under a short optimistic conflict check. =0 is the rollback valve — writes then serialize per database, as before.

Pipelining is what makes the optimistic concurrency model in Production Deployment the one you actually get: with it on, a write–write conflict between concurrent writers is detected and reported as the driver-retryable Neo.TransientError.Transaction.LockClientStopped rather than being avoided by serializing everyone. There are no lost updates either way — a client (or, better, a driver’s managed transaction) simply retries the transient.

The trade-off is isolation: pipelined autocommit writes run at snapshot isolation, the same level Bolt’s explicit transactions already use, so write skew is possible — two writers each reading a value and writing disjoint rows based on it leave no write–write overlap to detect. That matters only for a multi-row invariant that genuinely needs serializability; the sentinel-node pattern in Concurrency & retries turns such a case back into a detectable conflict. Turning the knob off restores serialized execution per database at a real throughput cost (measured ~4–5× on a disjoint-write workload).

Time-travel performance knobs

These change only how an AS OF read is served, never what it returns — a historical result is byte-identical whichever path runs. Both are read-path accelerations with a fallback that is always correct, so they are safe to flip either way; see Time Travel for the full picture.

KnobDefaultPurpose
PATINADB_PARTIAL_TIMETRAVELonReconstructs only the subgraph an AS OF read actually touches, when the query’s labels/relationship types are statically bounded (an unbounded shape falls back to a full reconstruct). Read-only, so a write-only workload pays nothing. =0 is the rollback valve — set it if you ever suspect the partial path; the full reconstruct then serves every read, slower but identical.
PATINADB_INDEXED_SNAPSHOTSoffBuilds a point-queryable .snapidx sidecar per snapshot so a cold single-vertex AS OF lookup is a point read instead of a full-graph rebuild. Opt-in because it costs disk. The build is lazy (triggered by a read that would benefit, never by a commit) and asynchronous (it never blocks the read that triggers it — that read is served by the scan path), so enabling it can’t stall a writer or the server’s apply loop.
PATINADB_MAX_SNAPSHOT_INDEXES16Caps how many .snapidx sidecars may exist at once (0 = unlimited). Bounds sidecar count, not bytes — worst-case disk is roughly N × the size of a snapshot file. Only relevant when PATINADB_INDEXED_SNAPSHOTS is on.

Why PATINADB_INDEXED_SNAPSHOTS is opt-in. It earns its keep on repeated historical point reads: the read that triggers a build is served by the scan path, so the sidecar only helps later reads. If your backend time-travels rarely, or reads a different point each time, leave it off — you’d pay the disk without the win. Turn it on when a known set of historical points is read repeatedly. | --query-timeout-secs | server flag | 300 | Per-request wall-clock budget for a REST read; overrun → 503. 0 = off. | | --max-concurrent-requests | server flag | 512 | Max in-flight HTTP requests; excess shed with 503. 0 = unlimited. |

Set an env budget to 0 (or unset) to fall back to the built-in default. Tighten them as DoS guards on a shared node, or raise PATINADB_MAX_HOPS for a genuinely deep graph. A RETURN … LIMIT k is the normal way to bound result size — there is no implicit result cap (an unlimited query streams every row).

For observability, set PATINADB_SLOW_QUERY_MS (server env, off by default) to log a WARN for any REST query slower than that many milliseconds — carrying the query’s normalized shape (literals + $params folded to ?), not raw values. Per-shape latency stats are also served at GET /mgmt/queries.

Commercial entitlements

The server resolves a set of commercial caps (cluster HA size, combined node+edge scale, database count, history-retention window, and two feature gates) either from the hard-coded Community ceiling or from a signed license token’s entitlement claims. This is configured entirely by which license you install (--license / PATINADB_LICENSE / <db-root>/license.key — see Licensing & Telemetry), not by a server flag. See Editions & Limits for the full Community/Pro/Enterprise table, what each limit does when you hit it, and how to read a running node’s resolved tier + live usage via GET /version (also scraped onto patinadb_entitlement_usage_ratio{axis} / patinadb_entitlement_limit{axis} Prometheus gauges).

Cache memory budget

patinaDB can keep a governed, RAM-budgeted cache of decoded objects / adjacency / query results above the OS page cache. The server defaults it ON, at PATINADB_CACHE_LIMIT=40% of the resolved memory limit, when you leave the flag/env var/config key unset — so a fresh node ships with caching already engaged. Set PATINADB_CACHE_LIMIT=0 (or the equivalent flag/config key) explicitly and the cache is entirely off — only the OS page cache and the existing plan/stats caches are used, at zero residual cost.

The budget derives from a cgroup-aware total (the real ceiling the kernel OOM-kills at in a container, not the host’s RAM): cgroup v2 memory.max → v1 memory.limit_in_bytes → host MemTotal, taking min(cgroup, MemTotal). From that total, PATINADB_MEMORY_LIMIT is patinaDB’s own-heap ceiling (not including the OS page cache), and four regions are carved from it. Each knob is an absolute size (8GiB), a fraction of its parent (40%), or auto; precedence is explicit-absolute > fraction > default, and fractions compose against the resolved parent.

Env varRegionDefaultParent
PATINADB_MEMORY_LIMITown-heap ceiling (excl. page cache)auto = TOTAL − min_freediscovered TOTAL
PATINADB_CACHE_LIMITcache pool (L1/L2/L3) — unset/0 disables caching40%MEMORY_LIMIT
PATINADB_WORK_MEM_LIMITaction reserve (concurrent query working memory)45%MEMORY_LIMIT
PATINADB_MEM_HEADROOMtransient-spike / allocator-slop / OOM safety15%MEMORY_LIMIT
PATINADB_CACHE_MIN_FREEpage-cache floor (system free RAM kept resident for the storage engine’s page cache)max(1GiB, 10%)discovered TOTAL

Every knob is also a patinadb-raft flag (--memory-limit, --cache-limit, --work-mem-limit, --mem-headroom, --cache-min-free) and a YAML config key (memory_limit, cache_limit, work_mem_limit, mem_headroom, cache_min_free), with the same explicit-flag/env > file > default precedence as every other setting.

The budget is validated at startup and fails loud (the node refuses to boot) when it over-commits — CACHE_LIMIT + WORK_MEM_LIMIT + HEADROOM ≤ MEMORY_LIMIT and MEMORY_LIMIT + CACHE_MIN_FREE ≤ TOTAL — with an error naming the offending knobs and the resolved bytes. When enabled, the fully-resolved budget (bytes per region) is logged at startup so it is never a mystery.

Example — a container with memory.max=16GiB, PATINADB_CACHE_LIMIT=40% and otherwise defaults: TOTAL=16GiB, min_free≈1.6GiB, MEMORY_LIMIT≈14.4GiB (TOTAL − min_free), then Cache ≈5.8GiB · Actions ≈6.5GiB · Headroom ≈2.2GiB. Set PATINADB_MEMORY_LIMIT=10GiB to leave more RAM to the page cache on a read-heavy deployment.

Internal defaults (informational)

These are not user-configurable flags today, but are useful to know:

SettingValue
Snapshot intervalevery 50 commits
Raft election timeout750–1500 ms
Raft heartbeat250 ms
BM25 parametersk1 = 1.2, b = 0.75
Full-text prefix/fuzzy expansion cap256 terms
Bolt streaming channel256 records (bounded)
Default database namedefault

On-disk layout

The --db directory contains the B-tree tables for the graph, the property/compound indexes, the engram log and snapshots, the full-text catalog and index data, and the persistent Raft log and state-machine metadata. Back up the whole directory as a unit. Each database is a subdirectory of --db.

Caching & Memory Tuning

patinaDB can keep a governed, RAM-budgeted cache of decoded graph objects above the OS page cache. The server defaults this cache ON, at PATINADB_CACHE_LIMIT=40% of the resolved memory limit, whenever you leave the flag/env var/config-file key unset — so a fresh node ships with L1/L2/L3/L4 caching already engaged. Turn it off with --cache-limit 0 (or PATINADB_CACHE_LIMIT=0, or cache_limit: "0" in the config file), any of which still win over the default — with the cache off, nothing is cached beyond the OS page cache and the pre-existing plan/statistics caches, at zero residual cost.

This chapter explains why the cache exists, the memory-budget model that keeps it from fighting the page cache, and how to size, observe, and tune it. For the raw knob table (env / flag / YAML) see Configuration → Cache memory budget; this chapter is the conceptual companion.

Status. All four RAM cache layers ship today: the memory-budget governor plus L1 decoded objects (vertices), L1 property values, L2 adjacency, and L3 query results — see the Layers section — all controlled by the one PATINADB_CACHE_LIMIT knob (on by default at 40%; 0 always disables, at zero residual cost). A fifth, L4 disk-backed victim tier for expensive L3 results also ships, opt-in via PATINADB_L4_VICTIM_MAX_BYTES (off by default).

Why a cache above the page cache

The storage engine — and, beneath it, the OS — already keep hot B-tree pages resident. That is level 0, and it is good: it avoids disk I/O. But L0 caches bytes, and it stops there. Every read still re-decodes those bytes: bincode / encode_for_indexVertex / Edge / AttributeValue, with each string value heap-allocated multiple times on the decode → hydrate → pack path. On a hot OLTP path (point lookups, MATCH (n) WHERE n.id = …, fan-out target reads) that decode CPU is paid again and again for the same object.

The patinaDB cache sits above the byte boundary and caches the decoded artifact, so a hit skips the decode and the string allocations entirely. It never mmaps or manages pages itself — L0 stays the I/O-avoidance layer, and the cache is strictly a read-side accelerator: writes always go straight to the B-tree store through the durable path, and the affected cache entries are invalidated, never written through. A cache hit can only make a read faster (or memory tighter), never return a stale or wrong result.

The memory budget model

The app cache is a second consumer of the same RAM the OS page cache needs. Grown naively it trades a decode-CPU win for extra page faults — a bad trade once the working set approaches RAM. So the budget is not a fixed number; it is an explicit, cgroup-aware partition that leaves the OS page cache a guaranteed floor.

How much do we have? (total discovery)

The number everything derives from is not the host’s MemTotal. In a container that is the host’s RAM, and trusting it is the classic Docker OOM-kill footgun (patinaDB ships in Docker). Discovery, in order:

  1. cgroup limit — cgroup v2 memory.max, else v1 memory.limit_in_bytes (the real ceiling the kernel OOM-kills at);
  2. host /proc/meminfo MemTotal (bare metal / unlimited cgroup);
  3. TOTAL = min(cgroup_limit, MemTotal).

A non-Linux host, or unreadable files, falls back conservatively (better to under-budget than to over-commit against RAM we can’t measure).

The partition and the free-floor

From TOTAL patinaDB resolves its own-heap ceiling MEMORY_LIMIT (this does not include the page cache — that is OS-managed L0, protected separately by the floor below), then carves four regions:

RegionEnv knobDefaultWhat lives here
Cache poolPATINADB_CACHE_LIMIT40% of MEMORY_LIMITthe decoded-object / adjacency / result caches
Action reservePATINADB_WORK_MEM_LIMIT45% of MEMORY_LIMITall concurrent query working memory
HeadroomPATINADB_MEM_HEADROOM15% of MEMORY_LIMITtransient spikes, allocator slop, OOM safety
Page-cache floorPATINADB_CACHE_MIN_FREEmax(1GiB, 10%)system free RAM the governor keeps below MEMORY_LIMIT so the storage engine’s L0 stays resident

PATINADB_MEMORY_LIMIT itself defaults to auto = TOTAL − min_free. The page-cache floor is expressed against system free RAM, not the internal MEMORY_LIMIT — it is the governor’s promise to the OS, orthogonal to the internal cache-vs-action split.

Each knob accepts an absolute size (8GiB, 512MiB), a fraction of its parent region (40%), or auto/unset for the default. Precedence per knob is explicit-absolute > fraction-of-parent > default, and fractions compose: a PATINADB_CACHE_LIMIT=40% is 40% of the resolved MEMORY_LIMIT, which may itself be a fraction of the discovered TOTAL. Every knob is also a patinadb-raft flag (--cache-limit, --memory-limit, --work-mem-limit, --mem-headroom, --cache-min-free) and a YAML config key, with the usual explicit-flag/env > file > default precedence — see the Configuration reference.

The elastic priority

Cache and query execution (“actions”) draw from the same heap and compete. Rather than a hard wall between them, the governor applies a priority with an elastic boundary:

under memory pressure:   actions  >  cache  >  (both yield to)  page-cache floor

A running query that needs working memory may evict cache to grow its action pool — a completing query beats a discardable cache — but the whole of MEMORY_LIMIT never pushes system-free RAM below PATINADB_CACHE_MIN_FREE. Two nested guards hold at all times: an internal one (cache + actions + headroom ≤ MEMORY_LIMIT) and an external one (MEMORY_LIMIT respects the page-cache floor).

Fail-loud validation

At startup patinaDB resolves the whole partition and refuses to boot if it over-commits — the errors name the offending knobs and the resolved byte counts:

  • CACHE_LIMIT + WORK_MEM_LIMIT + HEADROOM ≤ MEMORY_LIMIT (internal partition);
  • MEMORY_LIMIT + CACHE_MIN_FREE ≤ TOTAL (leave the OS its floor).

When caching is enabled the fully-resolved budget is logged once at startup and echoed on GET /mgmt/cache, so the sizing is never a mystery. The startup line looks like this (from a boot with PATINADB_CACHE_LIMIT=256MiB):

cache budget: memory_limit=… · cache=256.0MiB · work_mem=… · headroom=… · min_free=…

With caching off it instead logs cache: disabled (PATINADB_CACHE_LIMIT unset or 0).

A worked sizing example

Take a container with memory.max=16GiB and the defaults. TOTAL discovers as 16GiB; MEMORY_LIMIT=auto resolves to TOTAL − min_free (the floor keeps ≥1.6GiB of system RAM free so the storage engine’s L0 breathes); the three internal regions then split it roughly Cache ≈ 5.8GiB · Actions ≈ 6.5GiB · Headroom ≈ 2.2GiB. Tune from there:

  • Read-heavy / point-lookup workload — leave more RAM to the page cache: PATINADB_MEMORY_LIMIT=10GiB (≈6GiB stays with L0), and the decoded-object cache still captures the hot decode CPU.
  • Feed / dashboard workload with heavy repeated reads — bias toward the cache: PATINADB_CACHE_LIMIT=70%, where repeat-read cache value dominates.

The governor

The budget is the contract; the governor is the runtime feedback loop that enforces it, “always keeping free RAM in view”:

  • Observe. A lightweight background sampler reads MemAvailable (/proc/meminfo) on a 1-second interval — the ground truth of how much RAM is actually free right now, including pressure from other processes on the box.
  • Protect the page cache first. When free RAM drops below PATINADB_CACHE_MIN_FREE, the governor shrinks the app cache by the deficit before the OS starts reclaiming the page-cache pages the storage engine depends on. A hysteresis window keeps a steady stream of below-floor samples from re-evicting every tick (no thrash). Its standing bias is “shrink app caches first under pressure.”
  • Admission = scan resistance. Admission uses W-TinyLFU (a small frequency sketch): a candidate is admitted only when it is estimated hotter than the entry it would evict. So a one-shot full analytics scan streams through without evicting the hot OLTP working set — a full table scan won’t flush the cache. Eviction within the budget is segmented LRU (probation → protected), weighted by value density = (hit-frequency × cost-avoided) / bytes, so a cheap-to-recompute big object yields before an expensive small one.
  • Node-local, never stale. Caches are strictly node-local — nothing travels over Raft, and there is no cross-node coherence protocol. Correctness comes from generation tags: every write bumps the touched labels’ write-generation once at the sync() choke-point (the same mechanism behind fully_populated and the statistics catalog), and every cached entry carries the generation(s) it was built under. A lookup whose stamped generation no longer matches the live one is a miss + evict, never a stale hit — so a write to a label invalidates all of that label’s cached entries in O(1), with no scan and no per-key invalidation list. Time-travel (AS OF) reads and the copy-on-read write-resolve mirror both bypass the live cache.

What gets cached (the layers)

The design is a hierarchy above the page layer, extended incrementally:

LevelWhatStatus
L0OS page cache (bytes)pre-existing; avoids I/O
L1 objectsa vertex’s decoded label, keyed (db, uuid); a hit skips the bincode decode on point lookups and every fan-out target readshipped (increment 1)
L1 propertiesa vertex’s decoded property values (AttributeValue), keyed (db, uuid, prop); a hit skips the decode + string allocations on property projections, WHERE, ORDER BY, and group-by keys — the largest decode win (values dominate the allocation)shipped (increment 2)
L2 adjacencya hot anchor’s neighbour / incident-edge list per rel-type, paired with the edge-sorted index for feed pagination; eagerly invalidated on any incident-edge write (an edge write bumps no label generation, so eager invalidation — not the stamp — is the guarantee)shipped (increment 2)
L3 resultsmaterialized results for hot, pure-read, deterministic parameterized shapes (≤ 1 MiB, seen ≥ 2), stamped with every involved label’s generation so a write to any of them invalidates it; edge/traversal, all-vertices, and procedure queries are deliberately not cached (an edge write can’t be caught by a label stamp)shipped (increment 2)
L4 victim (disk)the cost-gated, disk-backed victim tier below L3-RAM: when an expensive L3 result is evicted, instead of discarding it, it is spilled to a separate file under <db_root>/_l4_victim/ and served from disk on a future identical read — L3-RAM → L4 → recompute. Survives a restart; validated by the same persisted (db-id-free) generation stamp.shipped

Also folded under the governor’s accounting are the pre-existing plan cache and statistics / fully_populated catalogs.

The RAM layers share one budget + the generation-tag invalidation discipline; GET /mgmt/cache and CALL patinadb.cache.stats() report each level’s live bytes + hit rate. L4 is a disk tier with its own byte cap (not part of the RAM budget) — see below.

L4 victim cache (disk)

The L3 result cache is pure-RAM under the governor’s byte budget, and it discards a result in two cases regardless of how expensive it was: on eviction (the coldest entry is dropped when over cache_limit) and on admission rejection (a fresh result that loses the scan-resistance comparison). In both cases the expensive CPU/IO of computing the result is thrown away and the next identical query pays full price. Meanwhile the box usually has spare disk.

The L4 victim cache catches exactly those victims: when an L3 result whose measured compute cost exceeds a threshold is evicted, it is spilled to a separate file under the database directory (<db_root>/_l4_victim/), and a future identical read is served from disk (deserialize an already-materialized result) instead of recomputed. The read order becomes L3-RAM → L4 victim → recompute, and an L4 hit is promoted back into RAM.

  • Provably as safe as L3-RAM. An L4 hit is validated against the same multi-label generation stamp L3-RAM uses — persisted beside the payload. A write to any involved label advances that label’s durable generation, so the stamp no longer matches and the entry is dropped on read (lazily) and recomputed. Never a stale hit.
  • Survives a restart (unlike the pure-RAM tiers): the persisted key and stamp are db-id-free — db identity is the file location and validity rests on the per-label generations, which are durable in the main database. So an expensive dashboard query stays warm on disk across a node restart.
  • Cost-gated. Only a result costing more than PATINADB_L4_VICTIM_MIN_COST_MS (default 50 ms) is spilled — a cheap query’s disk round-trip would cost more than just recomputing it.
  • Bounded by its own cap with LRU eviction: PATINADB_L4_VICTIM_MAX_BYTES (0 = off, opt-in, the shipped default). L4 is not part of the RAM budget — it lives on disk and never competes with the OS page cache.
  • Off the hot path. L4 is read only on an L3-RAM miss and written only on the (already-cold) eviction path, so no hot query takes an L4 lock. It inherits L3’s admission analysis verbatim (edge/traversal/unlabeled/procedure/non-deterministic shapes are never cached), is bypassed for time-travel (AS OF) and the write resolve mirror, and is truncated on clear_graph / snapshot install.
ENVMeaningDefault
PATINADB_L4_VICTIM_MAX_BYTESdisk cap for the L4 file (0 = disabled)0 (off)
PATINADB_L4_VICTIM_MIN_COST_MSonly spill victims costing more than this50

Enable it alongside the RAM cache (it catches RAM’s victims): e.g. PATINADB_CACHE_LIMIT=4GiB PATINADB_L4_VICTIM_MAX_BYTES=8GiB. Observability (a dedicated l4_victim metrics block + CALL patinadb.cache.stats row) ships alongside it — see Cache Observability & Tuning.

Observability & tuning

The cache exposes the same per-scope accounting three ways — CALL patinadb.cache.stats(), GET /mgmt/cache, and the patinadb_cache_* Prometheus series (with a ready-made Grafana dashboard) — so an operator can see which database, collection (label), or query shape is hot and how much RAM it holds.

The full treatment — every metric with its name on each surface, how to read each one, the Grafana panel walk, and a symptom → knob tuning playbook — lives in its own chapter: Cache Observability & Tuning. The short version: a high hit ratio on growing patinadb_cache_bytes means the cache is earning its keep (grow it); free-floor eviction with mem_available pinned at min_free means it is starving the OS page cache (shrink it).

When to disable. Set PATINADB_CACHE_LIMIT=0 explicitly to opt out of the 40%-on-by-default behavior. If the working set comfortably fits RAM with the page cache alone and decode cost is already negligible, the app cache adds accounting overhead for little gain — L0 plus the plan/stats caches is the right baseline. Disabling is a fully supported, tested configuration with zero residual cost.

Honest limits

  • Write-heavy scopes self-limit. A label under constant write churn bumps its generation constantly, so its cached entries rarely survive to a second hit — the cache naturally declines to cache churny data (its value density collapses) and spends the budget where it pays. The win is therefore workload-shaped: strong for read-heavy / feed / dashboard traffic, neutral for write-saturated.
  • The biggest failure mode is oversizing the app cache and starving the OS page cache — inducing the exact page-cache eviction and swap cliff the cache was meant to avoid. The page-cache floor and the governor’s “shrink app caches first” bias exist precisely to prevent this, and the gauges above make a misconfiguration visible rather than silent. When in doubt, size the cache conservatively and let a high hit rate justify growing it.

Cache Observability & Tuning

This chapter is the operator’s field guide to the governed cache: every metric it exposes, the surfaces that expose them, and a symptom → knob playbook for turning what you see into a configuration change.

It is the practical companion to Caching & Memory Tuning, which explains why the cache exists and how its memory budget is partitioned. Read that first for the concepts (the budget model, the governor, the four layers); read this to watch the cache in production and size it right. The raw knob table lives in Configuration → Cache memory budget.

Nothing to see while off. The server defaults the cache to 40% (see Caching & Memory Tuning); set PATINADB_CACHE_LIMIT=0 to turn it off. While disabled every surface below is a truthful all-zeros “just the OS page cache” report, never an error, at zero residual cost.

The surfaces

The same per-scope accounting is exposed three ways, from quickest to richest:

SurfaceReach for it whenDetail
CALL patinadb.cache.stats()you are already in a query session and want a fast per-scope lookone row per (level, scope): bytes, entries, hit rate
GET /mgmt/cacheyou want the whole report as JSON (scripts, ad-hoc curl)budget + per-level + per-scope + governor + Sankey + live free RAM
Prometheus /metricsyou want time-series, alerting, and the Grafana dashboardthe patinadb_cache_* gauge/counter set

All three read the same process-global governor, node-local by design — a node reports only its own cache. The cache is never replicated, so each node’s numbers stand alone (that is exactly why a per-node cache can never cause divergence — see Caching → The governor).

Metric reference

Every metric, grouped by what it tells you, with its name on each surface it appears on. A means that surface does not expose it. The Prometheus column is the exact series name — a wrong name reads nothing, so these are verbatim.

Labels: {level} is the cache layer (l1.objects, l1.properties, l2.adjacency, l3.results); {db} is the numeric database id.

Fill — how much RAM the cache holds

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Resident bytes per level (and per db)patinadb_cache_bytes{level,db}bytes (per scope)levels[].bytes, total_resident_bytesGrowing bytes with a healthy hit ratio = the cache is earning its RAM
Resident entry count per levelpatinadb_cache_entries{level}entries (per scope)levels[].entriesEntries × avg-entry-bytes ≈ bytes; a spike in entries with flat bytes = many small objects
Fill vs. the cappatinadb_cache_utilization_ratio{level}levels[].utilization, top-level utilizationEach level’s bytes / cache_limit; the levels sum to the overall fill. ~1.0 = full
Mean bytes per entrylevels[].avg_entry_bytesSizing sanity check — an L3 result row is far larger than an L1 object
The resolved hard cappatinadb_cache_limit_bytesbudget.cache_limitThe ceiling the sum of all levels is kept under

Hit / miss — is the cache paying off

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Cache hits (skipped a decode)patinadb_cache_hits_total{level,db}via hit_ratelevels[].hits, scopes[].hitsA hit is a decode + string-alloc avoided
Cache misses (fell through to storage)patinadb_cache_misses_total{level,db}via hit_ratelevels[].misses, scopes[].misses, sankey.missesThe fall-through to a real storage decode
Hit ratiopatinadb_cache_hit_ratio{level}hit_rate (per scope)levels[].hit_rate, scopes[].hit_ratehits / (hits + misses). The single headline number per level / scope

cache.stats reports hit_rate per (level, scope) — the finest grain, so you can see which collection is hot. The Prometheus hit_ratio is the per-level aggregate; /mgmt/cache carries both.

Eviction — is the cache under pressure

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Entries evicted by LRU/capacity, per levelpatinadb_cache_evictions_total{level}levels[].evicted_entriesRising alongside a low hit ratio = the working set doesn’t fit
Bytes freed by those evictions, per levellevels[].evicted_bytesThe byte-weight of the per-level LRU churn
Bytes evicted to protect the page-cache floorpatinadb_cache_evicted_free_floor_bytes_totalgovernor.evicted_free_floor_bytesThe page-cache-pressure signal. Non-zero = the governor is shrinking the app cache to keep the OS’s L0 resident (see tuning)
Bytes evicted to enforce the hard cappatinadb_cache_evicted_cap_bytes_totalgovernor.evicted_cap_bytesThe cache hit cache_limit with RAM to spare — you can afford a bigger cap

The free-floor-vs-cap split is the most operationally important pair here. Both are eviction, but they mean opposite things: cap eviction says the cache is bounded by your PATINADB_CACHE_LIMIT (raise it if you have RAM); free-floor eviction says the cache is bounded by the OS running low on free RAM (the cache is starving the page cache — shrink it).

Admission — is scan-resistance working

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Candidates admittedpatinadb_cache_admissions_totalgovernor.admissionsThe steady flow of newly-cached decoded artifacts
Candidates rejected (scan-resistance)patinadb_cache_rejections_totalgovernor.rejectionsA rejection is a one-shot scan element kept out of the hot set — healthy. Rises when a full scan streams cold keys past a warm, cap-full cache (victim-aware W-TinyLFU admission)

Invalidation — the write-churn tax

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Entries dropped by a stale generation on lookuppatinadb_cache_gen_invalidations_total{level}levels[].gen_invalidationsLazy invalidation: a write bumped a label’s generation, so its cached entries miss on next read. High on a scope = that label is write-churny
Entries dropped by a proactive scope invalidationlevels[].scope_invalidationsEager drops from clear_graph, db-drop, an edge write into L2, or the same-txn write window

Both count the same thing from two directions — the cost of a write to cached data. gen_invalidations is the passive, next-read discovery; scope_invalidations is the active, up-front purge. A scope with high invalidation and a low hit ratio is telling you the cache cannot help that data (it changes faster than it is re-read) — expected, and not worth budget (see Caching → Honest limits).

Budget & free RAM — the governor’s operating envelope

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
The system free-RAM floor the governor protectspatinadb_cache_min_free_bytesbudget.min_freeThe promise to the OS: keep at least this much system RAM free for the storage engine’s page cache (L0)
Live system free RAM (MemAvailable)patinadb_mem_available_bytesmem_available_bytesThe ground truth the sampler watches. Hovering near min_free = pressure
Own-heap ceilingbudget.totalpatinaDB’s own-heap limit (excludes the OS page cache)
Action reservebudget.work_mem_limitRAM reserved for concurrent query working memory
Headroombudget.headroomThe transient-spike / OOM safety margin

The pair to watch together is patinadb_mem_available_bytes against patinadb_cache_min_free_bytes: the gap between them is the governor’s remaining slack before it starts shrinking the app cache to defend the page cache.

L4 disk victim tier

The L4 victim cache is the optional disk tier below the RAM L3 result cache: when an expensive L3 result is evicted (or admission-rejected), it is spilled to a file under the db root and served from disk on a future identical query instead of recomputed. It is off by default (PATINADB_L4_VICTIM_MAX_BYTES=0), so its whole metric block is absent until you enable it. Enabled, it reports as a distinct block — it is not a governed RAM level, so it never shows up under levels[] or the RAM cache_limit.

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Resident disk entries / bytespatinadb_l4_victim_entries / patinadb_l4_victim_bytesl4.victim row (entries/bytes)l4_victim.entries / .bytesFill against the configured max_bytes cap
Valid disk hits (served + promoted)patinadb_l4_victim_hits_totall4.victim row hit_rate = hits/(hits+stale_drops)l4_victim.hitsEach hit skipped a full recompute of an expensive query
Hits promoted back to RAMpatinadb_l4_victim_promotions_totall4_victim.promotionsAn L4 hit re-enters L3 (a proven repeat)
Victims spilled to diskpatinadb_l4_victim_spills_totall4_victim.spillsExpensive results caught on evict/reject. spillshits = you are paying disk churn for results that never get reused — raise PATINADB_L4_VICTIM_MIN_COST_MS or lower the cap
Entries dropped stale on readpatinadb_l4_victim_stale_drops_totall4_victim.stale_dropsThe persisted generation stamp mismatched (a write touched an involved label) — high = the cached labels are write-churny (L4 can’t help them)
Entries LRU-evicted over the disk cappatinadb_l4_victim_evictions_totall4_victim.lru_evictionsThe disk tier is at max_bytes — the coldest entries are dropped first

The headline pair is spills vs hits: L4 is earning its keep when hits are a healthy fraction of spills. If spills dominate, the workload is expensive-but-not- repeated (or too write-churny — see stale_drops), and the disk tier is pure overhead. The writer runs on a bounded background channel off the read/evict hot path, so a slow disk never stalls a query — under backpressure a spill is simply dropped (a future recompute, never a wrong answer).

Reading each surface

In-query: CALL patinadb.cache.stats()

The fastest look — no HTTP, no dashboard, runs in any session:

CALL patinadb.cache.stats()
YIELD scope, kind, bytes, entries, hit_rate, generation
RETURN scope, kind, bytes, entries, hit_rate
ORDER BY bytes DESC
  • scope — the hot database / collection / query shape, rendered as a stable string: db:1, db:1/label:Ticket, or db:1/shape:1234 (a plan fingerprint).
  • kind — the cache level (l1.objects, l1.properties, l2.adjacency, l3.results, and — when the disk victim tier is enabled — a single l4.victim pseudo-scope row).
  • bytes / entries — resident size for that (level, scope).
  • hit_ratehits / (hits + misses), 0.0 when never accessed.
  • generation — reserved; yielded as NULL today (the column is kept for schema stability).

With caching disabled it returns zero rows (no levels are registered), never an error. See Procedures → Cache observability.

Over HTTP: GET /mgmt/cache

The complete report as one JSON document (admin-only — it is under /mgmt/). This is the richest single call: it carries fields no other surface has (avg_entry_bytes, evicted_bytes, scope_invalidations, the full budget, and the Sankey).

curl -s -u neo4j:secret http://127.0.0.1:21001/mgmt/cache | jq

An enabled node returns roughly:

{
  "enabled": true,
  "budget": {
    "total": 15461882265,
    "cache_limit": 6184752906,
    "work_mem_limit": 6957846769,
    "headroom": 2319282256,
    "min_free": 1717986918
  },
  "total_resident_bytes": 41231360,
  "utilization": 0.0067,
  "governor": {
    "admissions": 128934,
    "rejections": 20514,
    "evicted_free_floor_bytes": 0,
    "evicted_cap_bytes": 0
  },
  "levels": [
    {
      "name": "l3.results",
      "bytes": 12058624, "entries": 214,
      "hits": 90233, "misses": 1201, "hit_rate": 0.9869,
      "utilization": 0.0019, "avg_entry_bytes": 56348,
      "evicted_entries": 0, "evicted_bytes": 0,
      "gen_invalidations": 88, "scope_invalidations": 3,
      "scopes": [
        { "scope": "db:1/shape:8123", "db": 1, "bytes": 8388608,
          "entries": 40, "hits": 61022, "misses": 210, "hit_rate": 0.9966 }
      ]
    }
  ],
  "sankey": {
    "total_lookups": 402118,
    "misses": 14002,
    "layers": [
      { "name": "l3.results",   "label": "L3 result",   "value": 90233 },
      { "name": "l2.adjacency", "label": "L2 adjacency", "value": 121444 },
      { "name": "l1.properties","label": "L1 property",  "value": 130221 },
      { "name": "l1.objects",   "label": "L1 object",    "value": 46218 },
      { "name": "miss",         "label": "miss → storage","value": 14002 }
    ]
  },
  "mem_available_bytes": 9663676416
}

The levels array is ordered deepest-cache-first (L3 → L2 → L1 property → L1 object) — the same order the Sankey reads. Each level’s scopes are sorted hottest-first (by hits, then bytes) so the top row is the hottest collection or shape. The sankey block is the read-flow: total_lookups is the source width (Σ hits + Σ misses), each layer’s value is the hits that layer absorbed, and the trailing miss layer is the summed fall-through to a storage decode. Because the levels are consulted independently (a query may touch several), it is an aggregate share of all cache lookups, not a strict per-query cascade.

While disabled, enabled is false, the budget is all zeros, and levels is empty. See REST API → GET /mgmt/cache.

Prometheus & Grafana

/metrics exports the full patinadb_cache_* set (refreshed from the governor at scrape time — always current, no background task). The Docker demo in deploy/ ships a ready-made patinaDB Cache dashboard (grafana/dashboards/patinadb-cache.json, 20 panels); it is auto-provisioned by the compose stack, so once a node is scraped it appears in Grafana with no import step. To load it into an existing Grafana, import that JSON and point it at your Prometheus datasource. Its three panel rows map onto the metric groups above:

  • Cache Overview — five stat tiles: utilization % (sum(patinadb_cache_bytes) / max(patinadb_cache_limit_bytes)), resident bytes, cached entries, mem-available, and the overall hit ratio (rate(hits) / (rate(hits) + rate(misses))).
  • Fill & Hit Rate — resident bytes / utilization / hit-ratio / entries per level, the hit-vs-miss rate, and a hot databases table keyed by patinadb_cache_bytes{level,db}.
  • Eviction, Admission & Invalidation — the free-floor-vs-cap eviction-byte split (rate(patinadb_cache_evicted_free_floor_bytes_total) vs ..._cap_bytes_total), evicted entries and generation invalidations per level, admissions vs rejections, the miss → storage decode rate, and mem-available vs the free floor — the single most important page-cache-pressure panel.

The dashboard is all-zero until the cache is enabled. The full compose stack and its two provisioned dashboards are documented in the repo’s deploy/README.md.

Tuning from the metrics

This is the payoff: mapping what a surface shows to the knob that fixes it. Every knob below is documented in Configuration → Cache memory budget (each is an env var, a patinadb-raft flag, and a YAML key).

What you seeWhat it meansWhat to do
Low hit ratio + rising evictions_totalThe working set is bigger than the cache — entries are evicted before their second hitRaise PATINADB_CACHE_LIMIT (you have RAM to spend)
Non-zero evicted_free_floor_bytes_total + mem_available hovering near min_freeThe app cache is starving the OS page cache; the governor is shrinking it to defend the storage engine’s L0Lower PATINADB_CACHE_LIMIT (or PATINADB_MEMORY_LIMIT), or raise PATINADB_CACHE_MIN_FREE to give L0 a bigger floor
Rising evicted_cap_bytes_total while mem_available stays healthyThe cache is bounded by your cap, not by RAM pressure — there is free RAM going unusedRaise PATINADB_CACHE_LIMIT to let the cache grow into the free RAM
High gen_invalidations / scope_invalidations on a scope + low hit_rate thereThat label is write-churny; its entries die before a second readExpected — nothing to tune. The cache correctly declines to spend budget on it; don’t force it
Rising rejections_totalAdmission (scan-resistance) is keeping a one-shot scan out of the hot setHealthy — no action. This is the cache protecting your OLTP working set from an analytics scan
Near-100% utilization + high hit_ratio + low eviction rateWell-sized: the cache is full of hot data and rarely churnsLeave it. Grow the cap only if the hit ratio starts to dip
Fat miss → storage Sankey ribbonMost reads fall through to a storage decodeCache too small (raise the limit) or the workload is genuinely write-/scan-heavy (accept it, or see When to disable)

The two failure modes worth internalizing are the mirror image of each other:

  • Too small shows as low hit ratio + cap eviction + a fat miss ribbon while RAM is free → raise PATINADB_CACHE_LIMIT.
  • Too big shows as free-floor eviction + mem_available pinned at min_freelower it. Oversizing the app cache and starving the OS page cache re-creates the exact swap cliff the cache was meant to avoid — the free-floor split and the mem-available panel exist to make that visible before it bites.

When in doubt, size conservatively and let a high hit ratio justify growing. If the working set fits RAM with the page cache alone and decode cost is already negligible, the honest answer is PATINADB_CACHE_LIMIT=0 (the server defaults the cache on at 40%, so this must be set explicitly) — a fully supported, zero-residual-cost configuration; see Caching → When to disable.

Current limitations

  • generation in cache.stats is reserved — the per-scope write-generation is not carried on the observability seam yet, so the column is always NULL. It is kept in the signature for schema stability.
  • Prometheus is a subset of /mgmt/cache. avg_entry_bytes, evicted_bytes (per level), and scope_invalidations are only on /mgmt/cache — there is no Prometheus series for them. For alerting on those, scrape the endpoint directly.

Editions & Limits

patinaDB’s server (patinadb-raft) ships as one binary with three commercial editions gated by a signed license token. This chapter is the honest, public table of what each edition includes, what each limit actually does when you hit it, and how to watch your own usage before you do.

The guiding principle: the wall is for the successful, never the evaluator. Community is deliberately generous — a real evening project, or even a medium-sized proof-of-concept, should never brush against a cap. The limits start to matter once a deployment is running real production traffic: needs failover, needs more than a couple of databases, or needs to keep the whole audit history forever.

The table

AxisCommunityProEnterpriseWhat it’s for
Cluster voters (HA)1 (no failover)5unlimitedA single voter has no automatic failover — production reliability needs more than one, which is the primary commercial wall.
Nodes + edges (combined)5,000,000100,000,000unlimitedA backstop, not the main fence — generous enough that a real medium-graph evaluation never hits it.
Databases220unlimitedMulti-tenant / multi-workload isolation is a business feature.
History retention30 days rollingunlimitedunlimitedTime-travel itself is free in every edition (see below) — keeping the entire timeline forever, for compliance/audit, is the Pro/Enterprise sell.
Fine-grained (per-label) securityoffononGRANT/REVOKE READ|WRITE ON <db>:<Label> + the /mgmt/audit security log.
Point-in-time-recovery backupoffononGET /mgmt/snapshot?history=true — a portable backup that carries the whole engram timeline, not just HEAD state.
Telemetrymandatory (degrades if unreachable)best-effort, --disable-telemetrybest-effort, --disable-telemetry / fully air-gappedSee Licensing & Telemetry.

Every other capability — full Cypher + Bolt, graph algorithms, full-text and vector search, spatial queries, time-travel itself, RBAC with blanket per-database roles, engrams/diffs, change streams, everything in this manual that isn’t in the table above — is identical across every edition. The limits above are the entire commercial fence; nothing else is gated.

What happens when you hit a limit

Two different things happen depending on which axis you hit, and neither one ever touches data that’s already there:

  • The scale cap (nodes + edges) degrades to read-only. Once a database’s combined node+edge count would cross the cap, further writes are refused with a clear, actionable error (503 over REST, a Bolt failure) — your data stays exactly as it was, and every read keeps working normally. A DELETE that shrinks the graph back under the cap is still allowed, so you can always recover headroom without needing to buy anything. This is the same mechanism the community telemetry gate uses (see Licensing & Telemetry) — a write choke-point that either lets a proposal through or refuses it with an upgrade message.
  • The cluster/database/feature caps refuse the specific operation. Trying to promote a (max_voters + 1)-th voter, create a (max_databases + 1)-th database, or run a feature-gated command (a per-label GRANT, /mgmt/audit, ?history=true) past Community’s ceiling fails loudly with an upgrade message — but the running system is completely untouched. Nothing you already have breaks; the specific action you tried just doesn’t happen.

History retention in practice

Community’s 30-day rolling retention window means: AS OF <engram> / CALL patinadb.diff(<engram>) work for anything committed in roughly the last 30 days. Older history is periodically compacted (squashed into a single snapshot at the retention boundary) rather than kept forever — this shrinks the engram log, not the live graph, so HEAD data and every current query are completely unaffected. A Pro or Enterprise license simply omits the retention cap, so nothing is ever compacted and the full timeline (all the way back to the very first commit) stays queryable.

Reading your own usage

GET /version (no authentication required, same as /health) reports the resolved tier, every entitlement cap, and a live usage snapshot — so you can see headroom before hitting a wall instead of finding out from a failed write:

curl http://localhost:8080/version
{
  "name": "patinadb-raft",
  "version": "0.9.0",
  "tier": "community",
  "entitlements": {
    "max_elements": 5000000,
    "max_voters": 1,
    "max_databases": 2,
    "history_retention_days": 30,
    "fine_grained_security": false,
    "pitr_backup": false
  },
  "usage": {
    "nodes": 812345,
    "edges": 1204981,
    "elements": 2017326,
    "elements_usage_ratio": 0.403
  }
}

elements_usage_ratio is null whenever the cap is unlimited (a licensed Enterprise deployment, or any axis a license simply omits) — there is nothing to divide against, so no ratio is ever emitted for an uncapped axis. Watch this value (or scrape it — see below) and you’ll see the “you’re at 40% of the community node limit” signal well before you ever hit the wall.

Prometheus

The same usage figures are exported as gauges on the existing /metrics scrape endpoint:

  • patinadb_entitlement_usage_ratio{axis="elements"} — current / cap, current / cap, emitted only when the elements cap is finite.
  • patinadb_entitlement_limit{axis="elements"|"voters"|"databases"} — the resolved numeric caps themselves, so a dashboard panel can render headroom without re-deriving it from the license.

Wire these into the same Grafana dashboard as the rest of the cluster metrics (see Configuration Reference and Cache Observability & Tuning for the sibling observability surfaces) to get an early warning before a busy database approaches its scale cap.

Getting a license

A license carries signed entitlement claims — the specific numeric caps and feature flags a tier unlocks (an Enterprise license simply omits every numeric cap, so every axis resolves to unlimited). Licenses are issued by patinaDB; contact your vendor or account representative to obtain one for your tier.

See Licensing & Telemetry for how licenses are verified and installed once you have one. A node with no license (or an invalid/expired one) always resolves to the hard-coded Community ceiling in the table above. Check what a running node actually resolved to via GET /version (see Reading your own usage).

Licensing & Telemetry

patinaDB’s server (patinadb-raft) runs in one of two modes. The distributed Docker image ships without a license, so by default a node runs in community mode and sends an anonymous usage heartbeat to a telemetry server. Installing a license file switches the node to licensed mode for on-prem / offline / air-gapped operation, where telemetry is best-effort and can be turned off entirely.

This chapter explains both modes, documents exactly what the telemetry heartbeat contains (and, just as importantly, what it never contains), and shows how to obtain and install a license.

The two modes

Community mode (default — no valid license)

A background heartbeat is mandatory. On startup the node sends an initial heartbeat, then repeats it on an interval. It tolerates transient network outages (retries with backoff, plus a long grace window), so a brief blip is harmless. But it must not run indefinitely offline:

  • If no heartbeat succeeds within the grace window (default 72 hours), the node degrades. It refuses client writes with a clear 503 error and logs the reason loudly.
  • Reads keep working during and after the grace window — degradation only blocks writes.
  • Control / admin commands (creating databases, users, indexes) are not blocked, so an operator can still recover the node.
  • As soon as a heartbeat succeeds again, the block is lifted automatically and writes resume.

The refusal is surfaced as an HTTP 503 (REST) or a Bolt failure, with a message explaining that the node could not reach the telemetry server and is running unlicensed.

Licensed mode (valid license file)

A valid license unlocks on-prem operation:

  • Telemetry is best-effort: it is still sent by default (so the maintainer can see version adoption), but a failure to send never blocks anything and the node never degrades.
  • Telemetry can be turned off completely with --disable-telemetry.
  • --disable-telemetry is honored only with a valid license. An unlicensed node started with --disable-telemetry refuses to start (fail-closed) with a clear message — a community node must send telemetry.

What the telemetry heartbeat sends

The heartbeat is a coarse, anonymous JSON POST. The payload is a strict allowlist of counters and environment facts. Nothing outside the table below is ever sent. This is not a promise on paper alone: an automated guard test asserts the serialized payload’s key set equals this allowlist, and a second doc↔code drift test parses this very table and asserts it lists exactly the fields the code sends — so if a field is ever added to the wire without being documented here (or vice versa), the build fails. The table cannot silently drift from reality.

Every field, exhaustively:

FieldTypeMeaning
install_idUUIDA random id generated once and persisted at <db-root>/.patinadb_install_id. Stable and anonymous — not derived from anything identifying.
nonceUUIDA fresh random value generated per heartbeat. It exists only so the server’s signed response can be bound to this exact request (anti-tamper — see Response signing); it carries no information about you.
install_namestring | nullOpt-in, operator-chosen label (--install-name / PATINADB_INSTALL_NAME). Unset ⇒ null (anonymous), the default. This is the only free-text field and it is consent-based: it is whatever you type, and is never auto-derived from the environment (no hostname, no username).
versionstringThe node build version.
protocol_versionintegerThe Raft protocol version.
osstringTarget OS (e.g. linux).
archstringTarget CPU architecture (e.g. x86_64).
coresintegerLogical CPU core count (≥ 1). A coarse hardware-sizing signal.
memory_bytesintegerTotal host/cgroup RAM in bytes. A coarse hardware-sizing signal — not a live memory-usage figure.
uptime_secsintegerSeconds since this process started.
node_countintegerCluster membership size (voters + learners).
is_leaderbooleanWhether this node is currently the Raft leader.
database_countintegerNumber of databases in the registry.
total_verticesintegerAggregate vertex count across all databases (a coarse volume signal only).
total_edgesintegerAggregate edge count across all databases (a coarse volume signal only).
requests_per_minnumberCoarse recent request rate: total recorded query executions ÷ uptime minutes. An aggregate count only — carries no query text.
avg_query_msnumberMean query latency in milliseconds, aggregated (count-weighted) across all query shapes. An aggregate timing only — carries no query text.
license_statusstring"community" or "licensed".
license_customerstringLicensed mode only. The licensee’s own customer id from the license (the buyer of the license — this is not an end-user). Omitted in community mode.

What we NEVER send

The telemetry payload will never contain any of the following. When in doubt, it is left out:

  • No database content — no rows, no graph data of any kind.
  • No names — no property keys or values, no label or relationship-type names, and no database names.
  • No queries — no query text or any fragment of one (the requests_per_min and avg_query_ms figures are pure aggregate counters/timings).
  • No node or edge UUIDs.
  • No hostname, IP address, username, or location. (install_name is the one label you may choose to send — it is never read from the host.)
  • No RBAC user names, engram messages, or authors.
  • No personally-identifiable information of any kind.

Only the coarse counts and environment metadata in the table above leave the node.

How and when it sends; the endpoint

On startup the node sends one heartbeat, then repeats it on the interval. It is a single coarse anonymous POST — see what it sends above. The stable, anonymous install_id lets the maintainer count distinct installs across restarts without knowing anything about who you are; the optional install_name is a label you may choose to attach so your own installs are recognizable in your reports — it defaults to anonymous and is never taken from the host.

The endpoint is a compile-time constant baked into the binary — for the distributed community build, the maintainer’s server. In a release build it is NOT overridable: the --telemetry-endpoint flag and the PATINADB_TELEMETRY_ENDPOINT environment variable are compiled out, so a community node always phones the real host (redirecting it to /dev/null would defeat the point of community telemetry). Only debug/test builds accept an override, so the test suite can point at a mock server.

Honest note on the endpoint lock. Locking the endpoint is a soft deterrent, not a hard control. Telemetry is best-effort, so a determined operator can still firewall-block the host or patch the binary — we don’t pretend otherwise. The lock also means a licensed organization cannot point the heartbeat at its own telemetry server; the supported path for a licensed org that doesn’t want to phone home is --disable-telemetry (turn it off), not redirection.

SettingFlagEnv varConfig keyDefault
Heartbeat interval--telemetry-interval-secs <n>telemetry_interval_secs21600 (6h)
Grace window--telemetry-grace-secs <n>telemetry_grace_secs259200 (72h)
Opt-in install label--install-name <text>PATINADB_INSTALL_NAMEinstall_nameunset (anonymous)
Disable telemetry (licensed only)--disable-telemetrydisable_telemetryfalse

The endpoint itself has no run-time flag in release (see above). See the Configuration Reference for how flags, environment variables, and the config file combine.

Response signing (anti-tamper server authentication)

Locking the endpoint stops a community node from redirecting its phone-home, but a determined operator could still point telemetry.patinadb.org at a fake server (via /etc/hosts or DNS) that just returns 200 OK — dodging the degrade-after-grace without ever reaching the real host. To close that, the real telemetry server cryptographically signs its ping response and the node verifies the signature.

How it works:

  1. The node sends a fresh random nonce with each heartbeat (see the field table above), alongside its install_id.
  2. The server signs its response. The genuine server holds an Ed25519 private key and returns { server_time, signature }, where the signature covers nonce ‖ install_id ‖ server_time. Only a server holding that private key can produce a signature the node accepts.
  3. The node verifies the signature against a second Ed25519 public key embedded in the binary (TELEMETRY_RESPONSE_PUBLIC_KEY, separate from the license key), checks the nonce matches the one it sent, and checks server_time is fresh (within ±5 minutes, to bound replay).
  4. The verdict feeds the grace clock. A valid signature is a successful heartbeat (it resets the grace clock). A missing, malformed, forged, or stale signature is a failed heartbeat — it does not reset the clock, so a community node still degrades after the grace window.

A fake server has no private key, so it cannot produce a valid signature and therefore cannot stop the node from degrading. Because the signing key lives entirely outside the TLS / system-trust chain, this defeats even an attacker who has managed to insert a rogue CA into the host’s trust store (a plain /etc/hosts redirect is already stopped by strict TLS certificate validation — the node’s HTTPS client never disables cert/hostname verification).

Honest ceiling. This is an anti-forgery control, not an anti-patch control. A determined operator can still edit the embedded public key (or delete the verification) out of their own rebuilt binary, or simply firewall-block the host. Signing raises the bar from “edit one line in /etc/hosts” to “patch and recompile the binary” — a soft deterrent consistent with the community-telemetry model. The supported opt-out for a licensed org remains --disable-telemetry, not evasion.

Opting out

There is exactly one supported way to stop telemetry, and it requires a license:

  • --disable-telemetry, honored only with a valid license. It turns the heartbeat off completely — nothing is sent. An unlicensed node started with this flag refuses to start (fail-closed): a community node must send telemetry.

If you run community mode and simply stop reaching the endpoint (network block, air-gap), the node does not silently continue forever — after the grace window (default 72h) it degrades: client writes are refused with a 503, while reads keep working, until a heartbeat succeeds again. This “degrade after grace” behavior is the honest community-mode contract; the way to run offline without degrading is to install a license.

Installing a license (on-prem / offline / air-gapped)

A license is a compact, Ed25519-signed token. Because the node verifies it against a public key embedded in the binary, a license can be validated with zero network access — ideal for air-gapped deployments.

A node looks for a license in this order:

  1. The --license <value> flag or the PATINADB_LICENSE environment variable. The value may be either a path to a license file or an inline token.
  2. A license.key file in the database root (<db-root>/license.key).

If no valid license is found, the node logs why and falls back to community mode. An invalid signature or an expired license is likewise logged and treated as community mode — the node still boots, just unlicensed.

Expiry is checked live, not only at startup: a license that expires while the node is already running is detected on the next telemetry pass and the node drops into community-mode enforcement automatically, with no restart required.

To operate a node fully offline:

  1. Obtain a license token (a license.key file) from patinaDB, your vendor, or your account representative — see Editions & Limits for what each tier includes.
  2. Save it as license.key in the node’s database root (or point --license at it, or set PATINADB_LICENSE).
  3. Start the node. It logs licensed mode (on-prem) on success.
  4. Optionally add --disable-telemetry to stop all outbound heartbeats.

Privacy stance

patinaDB’s telemetry is designed to be anonymous by construction: the payload is a fixed allowlist of coarse counters and environment facts, guarded by an automated test that fails the build if any field outside the allowlist is added. No database content, no names, and nothing personally identifiable ever leaves the node. Licensed / air-gapped deployments can disable telemetry entirely.

Telemetry and licensing are node-local — each node phones home independently and they are never routed through the Raft log, so they add no cross-node coordination. For authentication and transport security of the data plane, see Authentication & TLS.

Limitations

This chapter is the honest inventory of what patinaDB does not do, or does differently from Neo4j. Read it before committing to a production workload.

Query language

  • ~3% of the openCypher TCK does not pass (~3755 / 3868 scenarios pass, ~97.1%; ~113 failures — this number moves by a handful run to run because of a known, harmless grouping non-determinism, see below). The TCK is a prioritized gap report, not a guarantee. The failures are not separate bugs — they cluster as follows:

    Cluster~ failsWhat it is
    Missing typed errors34Negative tests expecting a specific error (InvalidArgumentType, DeletedEntityAccess, MergeReadOwnWrites, …). patinaDB is more permissive and doesn’t raise them — a validation gap, not wrong results on valid queries.
    User-defined-procedure DDL/YIELD gaps~16Standalone CALL proc with no parentheses, standalone CALL proc(...) YIELD *, a backtick-quoted RETURN … AS \ident`alias, and missing compile-time validation (arg-count/type mismatch, YIELD-alias shadowing) for aCALL`. The 36 other procedure-fixture scenarios (registering + invoking a test procedure) now pass.
    Quantifiers / comprehensions over entity lists~14any/all/none/single and list comprehensions whose list holds nodes/relationships (not scalars), plus statically-true-predicate edge cases.
    ORDER BY edge cases~10Ordering by expression / aggregate / cross-type value ordering.
    Temporal edge cases~89-digit extended-year date parsing, datetime timezone serialization, duration.between over huge spans.
    Aggregation grouping~3Aggregates inside non-aggregate expressions; multiple aggregates on one variable.
    Long tail (lists, literals, precedence, …)~28Many features with 1–3 scenarios each; no further large cluster.

    OPTIONAL MATCH … WHERE was closed (2026-07-08): a per-clause WHERE on an OPTIONAL MATCH now filters the optional side before the left-join null-extension (a non-matching predicate yields NULLs, not a dropped row), and a chained OPTIONAL MATCH binds variables introduced by an earlier optional. A negative sub-second-only ISO duration (P1DT-0.001S) also round-trips correctly now.

    MERGE ON CREATE / ON MATCH is supported: SET x = <entity>, SET x += <map/entity> (nodes + relationships), SET x:Label, null-in-map removal, and combined ON CREATE+ON MATCH all work, plus MERGE matches-or- creates per row and undirected MERGE matches either direction.

    Known non-determinism (to fix): GROUP BY … ORDER BY <aggregate> LIMIT n applies the limit in group-insertion order rather than after sorting by the aggregate, so the surviving rows can vary run to run. Read-only today (it does not diverge Raft followers, which apply leader-resolved ops), but a real correctness bug.

  • User-defined procedures exist, but only as read-only, sandboxed WASM modules. CREATE FUNCTION <name> LANGUAGE wasm FROM 'file://…' EXPORT '…' registers a compiled WebAssembly module, callable as CALL wasm.<name>(...); it cannot write to the graph, and has no filesystem/network/clock/RNG access (deterministic-by-construction, so it’s safe to replicate). It is not a scalar UDF — it can’t be used inline in an expression like RETURN f(n.x). See WASM Procedures. This is a different mechanism from the TCK’s own ad-hoc test-fixture procedures (there exists a procedure …), which the TCK harness now implements directly against the same CALL dispatch — see the DDL/YIELD gap row above for what still fails there.

  • Constraints: all four Neo4j kinds — uniqueness, existence (NOT NULL), node-key/relationship-key, and property-type — are supported on both nodes and relationships. CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS UNIQUE (plus the Neo4j-4 ON (n:Label) ASSERT … form and the shorthand ON :Label(prop)), REQUIRE n.prop IS NOT NULL (existence), REQUIRE (n.p1, n.p2) IS NODE KEY (composite unique + existence over the key), REQUIRE n.prop IS :: <TYPE> / IS TYPED <TYPE> (property-type), and the relationship forms FOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL | IS :: <TYPE> | IS UNIQUE and REQUIRE (r.p1, r.p2) IS [REL|RELATIONSHIP] KEY are all supported, along with DROP CONSTRAINT name [IF EXISTS] and SHOW CONSTRAINTS; all replicate across a cluster and are carried in Raft snapshots + portable backups. Enforcement is at write time on CREATE, MERGE, and SET/REMOVE for nodes and on edge create/SET/REMOVE for relationships (a UNIQUE NULL is exempt; existence/key rejects a missing/null required property, including a SET n.p = null or REMOVE n.p; property-type rejects a present wrong-typed value but allows a missing/null one); CREATE CONSTRAINT refuses to run if the existing data already violates the constraint. FOREACH-body mutations are enforced too (GH #575, fixed): SET (per-property and map-form +=/=) and REMOVE inside a FOREACH (… | …) block route through the same enforcing core as a top-level SET/REMOVE, so a FOREACH write can no longer bypass a declared UNIQUE / existence / node-key / property-type constraint. A FOREACH-body CREATE pattern property that references the loop variable now resolves correctly (GH #578, fixed): FOREACH (i IN [1,2,3] | CREATE (:Tag {v: i})) creates nodes with v:1/v:2/v:3, not v: null — this was a separate, pre-existing data-correctness bug found while testing #575 (apply_foreach’s CREATE-path resolver only threaded vertex bindings, never the scalar loop variable, into the pattern-property resolver); toInteger(i) and other function-arg positions resolve it too. (A loop variable nested inside another map literal as a pattern property — {m: {inner: i}} — is still unsupported, but that is a separate, pre-existing parser limitation orthogonal to FOREACH — it reproduces identically with a plain UNWIND … AS i CREATE (n {m: {inner: i}}), no FOREACH involved.) MERGE/DELETE/SET n:Label inside a FOREACH body are rejected with a clear parse error (GH #579, fixed): the grammar used to accept them but the executor silently dropped them — a FOREACH (i IN [1] | MERGE (:X))/| DELETE m)/| SET m:VIP) returned success and wrote nothing. They now fail loud at parse time instead (SET/REMOVE/ CREATE inside a FOREACH body are unaffected); wiring MERGE/DELETE/label- add up to actually execute is a documented follow-on. Node-key and relationship-key uniqueness are each served by an index (an automatically registered backing compound index for node-key; the existing edge-property value index for relationship-key/uniqueness) — no separate index to create. Not supported: LIST<T> element-type refinement (bare LIST only), and bare composite-UNIQUE without existence. See Constraints. (Index DDL — CREATE INDEX ON :Label(prop) and compound :Label(p1, p2)is supported and replicates across a cluster; see the Data Model.)

  • CREATE EDGE SORTED INDEX is not carried in Raft snapshots. The DDL replicates via the Raft log (re-run per node), so all live nodes serve it, but a node that bootstraps purely from a streamed snapshot after the log is purged will lack the def and fall back to traverse + sort until the DDL is re-issued. (Correctness is unaffected — the fallback returns identical rows.)

  • No triggers, no stored procedures, no server-side scripting.

  • Side-effect counters are not tracked — query summaries don’t report nodes created, relationships deleted, etc. TCK side-effect assertions are accepted as no-ops.

  • shortestPath is supported, but the broader weighted/all-shortest-paths and full APOC-style procedure library are not.

  • Some advanced predicate forms (ALL/ANY/NONE/SINGLE list predicates, COUNT { … } subqueries, pattern comprehensions) are evaluated per-row (O(N)) — not yet lowered into the streaming operator tree for index/hash-join shortcuts — and the quantifiers do not yet fully handle lists whose elements are nodes/relationships (see the TCK table above).

Full-text search

  • String properties only (matching Neo4j). No numeric/range terms inside the full-text query string.
  • No phrase slop / proximity"a b" matches only exactly-consecutive terms; "a b"~3 is not supported.
  • No highlighting or snippet extraction.
  • Prefix/fuzzy expansion is capped at 256 terms per term to bound cost; very broad prefixes silently match only the first 256 dictionary expansions.
  • Postings are updated read-modify-write with no segment merging. This is fine for typical write rates but is not engineered for very high write throughput over a large indexed corpus.

Storage & scale

  • Single-machine storage. Each node holds the full graph; there is no horizontal sharding of one graph across machines. The cluster replicates, it does not partition.
  • The whole graph is on one B-tree file per database (patinadb.redb). patinaDB targets small-to-medium graphs, not multi-terabyte datasets.
  • Time-travel speed (not memory) grows with delta distance. Snapshots are captured and reconstructed as a streamed O(chunk) record run — building a periodic snapshot and rebuilding a past state both use bounded memory (the reconstruction runs into an on-disk temp store, not RAM), so the graph is not capped by memory. What still grows is time: reconstructing a point far from the nearest snapshot replays a longer delta chain, so frequent snapshots keep far-back queries fast. A narrow AS OF read now scales with what the query touches, not the whole graph, once its snapshot window is warm (RFC-0011 increment 1): the nearest snapshot is materialised once and shared across every time-travel read in its window, and a query reads through it lazily with only the in-window deltas overlaid — so history browsing / AS OF dashboards / diff exploration over a large graph are fast. The first read into a cold snapshot window still pays the O(graph) snapshot materialisation (cold O(query) for point reads is RFC-0011 increment 2).
  • A plain LOAD CSV … CREATE buffers into one transaction. The CSV source streams row by row, but a bare (un-chunked) write buffers all resolved ops into a single transaction (on the server, one Raft entry) before committing — so a very large LOAD CSV … CREATE has an O(rows) memory cliff. Wrap it in CALL { … } IN TRANSACTIONS [OF n ROWS] to chunk it into many small commits with O(n) memory — one engram per chunk, and on a cluster one client_write per chunk. For a large columnar file already on the server’s disk, POST /mgmt/upsert (index-accelerated match-or-create, chunkable) is faster still. LOAD CSV … RETURN row (read) streams end-to-end. See Bulk Loading & Import.
  • Some sort paths are O(N). Single-key ORDER BY uses an index fast-path, but the general executor still has O(N) sort paths for cases not covered by the fast-path (e.g. an un-limited multi-key sort with no covering index) — see Query Planning.
  • On-disk format evolution needs a migration or a dump/reload. Each database records an on-disk schema version. Because the storage encoding is non-self-describing bincode, appending a variant to the end of a persisted enum is compatible and needs nothing, but a struct field add/remove/reorder, an enum variant insert/reorder, or a key/value byte-encoding change is breaking and bumps the version. An older database is not unconditionally bricked on open: if a migration step is registered for the version boundary it is upgraded in place; if none is registered the open fails loud and points you at the dump/reload path — export with the old build via the portable backup (GET /mgmt/snapshot), upgrade, then import (that backup is JSON and cross-version by construction). A database written by a newer build than the running binary is always rejected (there is no downgrade).
  • Cross-tree atomicity is guaranteed by the embedded B-tree storage engine, whose write transactions are natively cross-tree atomic. A commit, a Raft-applied entry, and a snapshot install each land as an all-or-nothing transaction of any size: a crash before the terminal durability barrier rolls the store back to the prior root. This is proven non-vacuously by the crash-recovery harness (a torn cross-tree flush at the property↔value-index seam recovers atomically). The Raft leader resolves a data-mutating query against a throwaway mirror and applies the resolved batch as one crash-atomic engram, so a very large single-query write never partially mutates the live graph.

The server

  • Native TLS (--tls-cert/--tls-key) covers REST + management + peer RPCs and the Bolt endpoint (bolt+s:// / wss://) (Authentication & TLS). It’s opt-in; without it everything is plaintext (terminate at a reverse proxy instead).
  • Firewall the Bolt port regardless of TLS. Issue #440 closed the unauthenticated “slowloris” connection-exhaustion vector: a silent, a stalled-mid-handshake, or an idled-after-HELLO socket is now bounded in time by --bolt-handshake-timeout-secs (default 10) / --bolt-idle-timeout-secs (default 300) and can never spend the --max-bolt-connections permit budget (default 1024, acquired only after a successful LOGON — see Connection resource limits). The Bolt endpoint is nonetheless still best run reachable only from your trusted backend network, not the open internet — every other resource (accepted file descriptors, per-message CPU) is still finite.
  • RBAC: global + per-database roles + per-label grants (row-filtered where provably safe) + optional strict relationship-type/property modes + optional closed-mode tenant isolation. Per-user accounts with admin/writer/reader roles, per-database overrides (GRANT … ON DATABASE …), and per-label READ/WRITE grants (GRANT READ|WRITE ON <db>:<Label> …) exist (Authentication & TLS). By default (open mode) a user’s global role reaches every database, including one it holds no grant on; --rbac-closed opts into real database-level deny for non-admins (see Authentication & TLS). Per-label enforcement for a read query is row-filtering, not a blanket reject, wherever it can be proven safe: a read that touches only granted labels returns full rows; a read that would observe any ungranted label (e.g. a traversal into it) returns an empty result — as if the ungranted nodes didn’t exist — rather than every property/value on the granted side being withheld one by one; only a read the analyzer cannot statically classify (an unlabeled MATCH (n), a procedure CALL, or a query that projects a whole matched entity that might carry an ungranted secondary label) is rejected outright, since row-filtering it could otherwise leak data through a side channel. A write touching an ungranted label is always rejected (a write can’t be partially applied). Two more layers are opt-in and reject-based (not row-filtered): --rbac-rel-grants (relationship-type grants — without it, a label-scoped user still reads all edge data) and --rbac-rel-property-grants (per relationship property). Per-property privileges on nodes are not yet implemented. Auth is fail-closed: an empty password refuses to start unless you pass --insecure-disable-auth.
  • Security audit log is node-local + in-memory. Authenticated write/admin/DDL operations and authorization denials are recorded (GET /mgmt/audit, plus tracing target patinadb::audit), but the ring is bounded, not Raft-replicated, and not persisted across a restart; reads are not audited.
  • No encryption-at-rest. The on-disk graph (and the audit ring) are not encrypted by patinaDB — use OS-level disk encryption. This is a storage-backend change tracked as a follow-on.
  • No online membership reconfiguration UI — cluster changes go through the /mgmt/* HTTP endpoints by hand.
  • Writes are linearizable through Raft, so write latency includes a consensus round trip on a multi-node cluster. (A single --bootstrap node has no such cost.)
  • Reads are served from a node’s local applied state by default. On a follower/learner this can momentarily lag the leader. For a read that reflects all committed writes, request "consistency": "linearizable" on /cypher (leader-only, one round-trip — see High Availability). This knob is REST-only; the Bolt path always reads locally.
  • Explicit Bolt transactions are snapshot-isolated, not serializable — and your client MUST retry the conflict error. BEGIN pins a consistent snapshot (repeatable reads); COMMIT is first-committer-wins (a write-write conflict against a commit made since the snapshot is rejected with a transient Neo.TransientError — surfaced to a Neo4j driver as the same LockClientStopped-shaped transient conflict a real Neo4j cluster can also raise), which prevents lost updates. This is a hard requirement for concurrent multi-writer correctness, not an edge case to shrug off: use your driver’s managed-transaction API (execute_write/executeWrite, or equivalent), which retries a transient error automatically — a client that treats it as a hard failure will see avoidable errors under write contention even though the database itself never lost or corrupted any data. The same pipelined-write conflict-and-retry applies to autocommit writes on the server too. Snapshot isolation does not prevent write skew (a read-write conflict with disjoint write-sets), and it is not serializable and not linearizable. For an invariant spanning rows one transaction reads and another writes, use a single autocommit statement, a sentinel node your transactions all touch (forcing the conflict to be detected), or an application-level guard. See Bolt & Neo4j Browser.
  • An explicit (BEGIN…COMMIT) transaction’s snapshot base is fast for a selective read, and still has a residual cost for a genuinely-scanning one at very large scale. Earlier, a managed transaction rebuilt its whole snapshot-isolation base from scratch on every statement, which under concurrent write load could mean nearly every transaction re-materialized the entire graph. That base is now shared and incrementally maintained, and a further fix removed a redundant planning-time cost for the common case — so a selective managed read (a point lookup, an indexed match) stays fast and roughly flat as the graph grows, even under heavy concurrent writes. What’s not yet fixed: a managed transaction that genuinely scans a large, uncovered portion of the graph still pays a cost that grows with graph size under write churn; closing that fully needs an MVCC-style read handle that has been built and reviewed but is not yet merged to main. If your workload runs frequent large scans inside explicit transactions at very large scale, measure before committing to it.

Time travel

  • Reads only. Time travel never writes. To bring a past state back to the live graph, use CALL patinadb.restore('<id>') — it promotes that state to HEAD as a new engram (append-only; see Engrams).

Compatibility

  • patinaDB implements a subset of Neo4j. The Bolt endpoint and the system shim are sufficient for the official drivers and the Neo4j Browser, but Neo4j-specific admin surfaces, APOC, GDS, multi-database access control, and enterprise features are not present.

Tracked work

The limitations above that are called out as “not yet” or “a follow-on” are on the roadmap (LIST<T> element-type refinement, the O(N) sort fast-path integration, lowering list-predicate/subquery forms into the operator tree, edge-sorted-index defs in Raft snapshots, and the deferred full-text items above). If a limitation here blocks you, reach out to your vendor or account representative to check on its status.

Glossary

Anamnesis — patinaDB’s optional, auto-projected provenance system: every write’s history is mirrored into a companion <db>__anamnesis database as a W3C-PROV-shaped graph (who changed what label/property, when, with what confidence/source). Opt-in per database; see Anamnesis.

AttributeValue — patinaDB’s tagged property value type: String, Integer, Float, Boolean, Null, List, Map, the temporal types, Point, Polygon, and Path.

ANN (approximate nearest neighbor) — a vector search that trades exact recall for sublinear speed. patinaDB’s vector index is ANN via IVF-Flat.

BM25 — the ranking function used by full-text search (k1 = 1.2, b = 0.75).

Bolt — Neo4j’s binary client protocol. patinaDB’s server speaks it over raw TCP and WebSocket, so Neo4j drivers and the Browser connect directly.

CDC (change stream) — a live feed of committed graph changes (GET /changes), replayed from the engram log and then tailed in real time. See Change Streams (CDC).

Edge-sorted index — a per-anchor index that keeps a vertex’s outgoing (or incoming) neighbors over one relationship type pre-sorted by a target property, serving ORDER BY … LIMIT k traversals without a fan-out sort. See Edge-Sorted Indexes.

Engram — one committed unit of change: a list of deterministic delta operations plus metadata (id, timestamp, message). The basis of history, diffs, time travel, and replication.

Entitlement — a signed capability/limit a license unlocks (cluster voters, node+edge scale, database count, history retention, fine-grained security, PITR backup). See Editions & Limits.

Compound index — a multi-field B-tree index accelerating equality-prefix + sort queries. Maintained automatically.

DeltaOp — a single low-level mutation (create/delete vertex, set property, set label, create/delete edge). Engrams are lists of these.

Diff (single) — a git-show-style view of one engram.

Diff (range) — a structural, move-aware comparison between two reconstructed graph states.

Fork — create a new database seeded with another’s state at a chosen engram (FORK DATABASE … [AS OF …] INTO …); the fork gets its own independent history.

Embedding — a fixed-length list of floats representing an item in a vector space, stored as an ordinary list-valued property. Queried by similarity via a vector index.

Full-text index — a user-defined BM25 inverted index over string properties of a label or relationship type.

IVF-Flat — the vector index strategy: k-means centroids partition the space into nlist cells; a query scans only the nprobe nearest cells. The centroids are trained once and replicated, so every cluster node returns identical results.

Learner — a Raft node that replicates and applies the log but does not vote; acts as an asynchronous read replica.

openraft — the Rust Raft implementation the server is built on (0.9).

Pin — mark an engram so squash never coalesces it, keeping the point-in-time it marks reachable. Tags pin automatically.

Property-value index — a label-scoped, order-preserving index over a property, enabling O(limit) sorted pagination and efficient equality/range scans.

Quorum — the majority of voters required for Raft to commit. A 1-node cluster has a quorum of 1 (no redundancy); a 3-node cluster tolerates 1 failure.

Reconstruct — rebuild a past graph state from the nearest snapshot plus forward delta replay; the mechanism behind time travel.

Snapshot — a full-graph capture, taken periodically (every 50 commits) to bound reconstruction cost, and shipped in Raft snapshots to bootstrap a node.

Squash — compact a run of old engrams into one synthetic genesis, keeping recent (and pinned) history; the live graph is unchanged, only the log is compacted.

Tag — a named, pinned, snapshotted reference to an engram (like a git tag), so you can time-travel to a meaningful point via AS OF TAG. Replicates across a cluster.

TCK — the openCypher Technology Compatibility Kit, the conformance suite used to measure Cypher coverage (~97% passing).

Time travel — running a read query against the graph as it was at a past engram (--at, USE … AS OF, or the at request field).

Vertex / Edge — a node / a directed, typed relationship in the property graph.

Vector index — a user-defined ANN (IVF-Flat) index over an embedding property, queried with Neo4j syntax (db.index.vector.queryNodes) by cosine or euclidean similarity.

Voter — a Raft node that participates in elections and commit quorums.