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.22.0.

Revision 0.22.0

Hardens backup/recovery, ordered mutation execution, query resource limits and transaction lifetimes. Adds mixed-direction partial-prefix ordering and release SBOM/notice verification. See 0.22.0 Release Notes for compatibility boundaries and the pending candidate qualification. Historical soak results do not qualify this new candidate; publication is still separate.

Revision 0.21.0

Improves eligible cold filtered traversals and aggregates, preserves procedure bindings after WITH and in correlated subqueries, and adds independently checked WASM workload coverage. See 0.21.0 Release Notes for the separate six-hour non-WASM and thirty-minute WASM qualification results. Release preparation does not imply that tagged artifacts are already published.

Revision 0.20.0

Adds stable multi-key ordered-edge pagination with residual filters and ordinary graph continuation. This revision also includes the maintenance jobs, recovery, diagnostic tracing and cache improvements merged since 0.17.0. See 0.20.0 Release Notes for the three-hour mixed qualification, upgrade boundaries and remaining cold-aggregation and author-page latency gaps. The version number is not a claim of universal production readiness.

Revision 0.17.0

Fulltext index construction groups postings through a bounded external-sort buffer instead of repeatedly rewriting each term list for every document. Storage format and synchronous incremental maintenance remain unchanged. See 0.17.0 Release Notes for measurements and boundaries.

Revision 0.16.0

Adds bounded parallel vector-index training, cached initialization distances, and incremental candidate consumption during vector search. See 0.16.0 Release Notes for scope and validation.

Revision 0.15.1

Portable restore parses its bounded upload incrementally instead of buffering the full JSON document first. Administrators can explicitly configure a larger restore byte cap; decoded graph/history objects still require RAM. See Backup & Disaster Recovery. The revision also fixes the backup qualification harness; no production approval follows from the bump.

Revision 0.15.0

This revision adds region-aware Bolt routing and scoped causal bookmarks, includes the post-0.13.0 correctness/security fixes, and provides automated release qualification. Read the 0.15.0 release notes for upgrade boundaries and the still-open concurrent-onboarding latency gate. Publication of this manual is not production approval or a binary release.

Revision 0.13.0

This revision preserves valid property-population metadata when existing property values are overwritten, including replicated bulk writes. It is a generic storage correction, not a Jira-specific query rewrite (#1261). It also adds actual snapshot/purge indices to the operator metrics and reproducible logged-fixture worker restart tests. See First Customer for measured local recovery coverage and the required backend retry behavior. A version bump is not WAN qualification or production release approval; see Limitations.

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.

0.22.0 Release Notes

Released 2026-10-25 from the merge of PR #1510 (4254bca0, tag v0.22.0). It includes everything since 0.21.0. The full list is in the repository changelog.

Highlights

  • History epochs (RFC-0038). Opt-in sealing, archiving and cluster-wide hot release of old history; released history is read from the verified archive. See History epochs.
  • Identity and access management (RFC0037). Profiles, API keys, optional OIDC/OAuth providers and SCIM-managed identities. See IAM.
  • Database incarnations. Each database carries a replicated incarnation UUID; drop and recreate, and restore, start a new one.
  • Query engine. The RFC 0042 planner and executor switches are on by default (factorization, degree statistics, cost-based entry choice), with bounded per-query memory admission across sort, join, UNWIND and collect paths. See Query planning.
  • Storage. Secondary labels moved inline into the adjacency (schema 8, RFC 0040 increment 1); wide vertex-property values are stored once per owner (schema 7). See Storage.
  • Stability under memory and disk pressure (PR #1510). Paced checkpoint packaging, batched and recoverable squash cleanup, bounded redb freed-page reclamation per commit, single-barrier fork seeding, and a BEGIN wait bounded by the transaction deadline.

Data safety and recovery

Bulk upserts enforce schema constraints, keep graph entities intact across chunks and preserve unambiguous pre-load rollback targets. Restored baselines are checkpointed before admitting replicas, with snapshot publication and purge ordering enforced. Backup archive v2 retains property registrations and named indexes that older exports could lose.

Forced snapshot replacement durably retains the replay tail or fences the incomplete repair. Backfill errors are classified per call and retain their original cause; repair latches remain database-local across restart. Vector preparation propagates scan errors and publishes complete definitions last.

Queries and operational limits

Completed deferred tag snapshots release their cached read transactions, including when another snapshot path completed the work. Durable tags and pending snapshot captures remain intact. This removes a source of retained old database pages; fresh long-run memory qualification remains required.

Spill-heavy index loading now merges at most 32 input runs at a time, adding one output file for an intermediate merge. This fixes reproduced file-descriptor exhaustion while preserving duplicate ordering and cleanup on failure or early drop. The bound applies to each active merge, not all descriptors in the process; no throughput improvement is claimed. See the implementation and regression evidence.

FOREACH executes ordered mutation clauses with lexical scope and authorization. Actual nodes/relationships retain their identity through containers and Bolt, JSON and WASM conversions; UUID scalars remain scalars and real Paths stay Paths. Store scalar references such as id(n) rather than entity/path property values.

Grouped ordering preserves renamed keys. Eligible partial-prefix multi-key ordering supports mixed directions while retaining NULL/tie-break rules, materialization limits and execution errors through SKIP. Seeded UNWIND and FORK DATABASE share their corresponding row/database limits. Terminating an idle transaction wakes its owner. Cache shape-admission history is bounded. Decoded rotation request secrets are zeroized on drop, including cancellation; this is not a guarantee about every raw transport or parser buffer.

COUNT subqueries in projections and WHERE comparisons share a seeded plan, including correlated filters. Single-pattern COUNT accumulates matches without materializing result rows; evaluation errors remain errors. General list-predicate lowering and decorrelation are still pending. Eligible unsorted equality filters use bounded advisory index probes, and SHOW INDEXES includes compound definitions.

Authorized management audit responses include stored peer_ip and origin. Both embedded UIs replace the vulnerable single-file plugin dependency chain with a shared offline bundler. These changes do not inherit the earlier soak.

Upgrade and downgrade boundaries

  • Storage schema is 8. Opening a schema-7 store migrates it in place and rebuilds the inline label data; schema-6 stores must be recreated or reimported (no on-disk migration). Do not open a migrated store with an older binary.
  • Peer protocol is 22 and the Raft snapshot format is 8. Physical snapshots with schema-8 bodies require protocol 22. Feature floors (incarnations 17, IAM 19, epoch metadata 20, history restore 21) apply once the corresponding records exist; new members must then speak at least that version. A matching protocol number alone does not qualify every mixed-version scenario.
  • New backup readers accept archive formats 1 and 2. Old readers reject format 2; verify restores with compatible binaries before retiring older exports.
  • Local snapshot-install intent changes from 1 to 2 and retains v1 reading. Do not downgrade across an unfinished v2 install/recovery intent.
  • Direct Rust runtime-value consumers must handle the added Entity variant. JSON containers expose structured entities; Bolt returns native entity containers. Entity/path property values are rejected rather than coerced; store id(n) explicitly.

Qualification

PR #1510’s three-hour five-node mixed pressure soak passed on the released product code (run ae8ab7dd43ed, independently audited, no OOM), and its CI passed all 47 checks. This is one passing soak on one host, not a latency guarantee: the write tail under squash and checkpoint load is reduced but not solved (#1516, #1512). The RFC-0024 token release gate (#1151) remains a separate decision, including acceptance of finite token retention and eviction at capacity. A release is not a production approval for any particular deployment.

0.21.0 Release Notes

This revision includes the cold-query improvements merged after 0.20.0 and the seeded-procedure correction. Release preparation, published artifacts and production approval remain separate steps; check the repository release status.

Query execution

  • Eligible filtered aggregates reuse exact indexed string values rather than repeatedly reading graph properties. Unsupported shapes retain the ordinary executor, including null, mixed-domain and resource-budget fallbacks.
  • Long ordered-edge traversals can prepare selective residual candidates; short pages continue streaming. Filters, ordering and multiplicity remain part of query semantics, not application-specific rewrites.
  • Parallel workers preserve filtered entry selection, pinned parameters and bounded aggregate covering. Query parallelism remains opt-in.
  • Bounded top-K sorting enforces the retained-row budget, including offset windows, and reports resource exhaustion instead of truncating answers.

Procedure correctness and WASM

Procedure calls after WITH and inside correlated subqueries now retain imported row bindings. Previously the fallback could invoke a procedure with unbound arguments; a real WASM guest reproduced the failure. The fix is generic and preserves database-scoped procedure resolution.

The WASM manual explains bounded application calls, backend authorization before invocation, module registration and trust boundaries. WASM remains subject to its existing experimental-feature requirements.

Qualification and limits — #1261

The preceding frozen cold-query candidate passed twelve verified cold starts with 24 correct answers and a strict six-hour mixed run with 113,327 successful timed HTTP attempts. This was a non-WASM workload, not a six-hour WASM test. Exact artifacts and prior failures are recorded in repository docs/cold-query-qualification-1261.md.

The subsequent procedure-fix candidate passed fresh five-node WASM controls, including learner restart, module replication and a deliberately unavailable module. Only the failing WASM client stopped; ordinary reads continued. A separate thirty-minute mixed run passed all 11,393 timed HTTP attempts, including 360 independently checked WASM page requests. WASM HTTP p90/p99 were 250.5/465.2 ms, including backend and transport, not isolated engine time. See repository docs/wasm-mixed-1261.md for hashes, rates and raw evidence.

Both workloads used one SSD host with three voters, two nonvoting learners, three Rust/neo4rs backends and simulated regional delays. The fixture contains 334,770 tickets, 2,201,041 comments and 5,488,094 edges, not one million tickets. The measurements predate the metadata-only version bump; they are not new measurements of a published release binary.

This does not qualify real WAN deployments, maximum write throughput, batch synchronization, arbitrary hostile guests or every cold query shape. No storage or peer-protocol format changes are introduced by this revision.

0.20.0 Release Notes

This revision consolidates the changes since 0.17.0. There are no implied 0.18.0 or 0.19.0 releases. Release preparation, tagged artifacts and production qualification are separate steps; consult the repository release/tag status before deploying an artifact described by this manual.

Ordered graph pages

Edge-sorted indexes now support eligible multi-key pages, necessary primary cursor bounds, deterministic residual filters and ordinary additional graph hops. The executor orders complete primary-value families before selecting secondary keys. It preserves matching-row multiplicity and checks filters before counting page rows. Existing index definitions and persisted entries are reused; this feature does not require rebuilding them.

Planner and executor controls cover the unchanged backend-shaped query, identity projections, snapshots, ties, cursor boundaries, errors and cancellation. Unproved query shapes and primary-value domains retain the general executor. Selective filters, large equality groups and offset pagination may still require substantial work. Use keyset pagination where appropriate; no fixed candidate count or universal latency guarantee is implied.

Other changes since 0.17.0

  • Maintenance jobs: asynchronous tag and backup work, observable node-local execution and history maintenance operations.
  • Backup and recovery: retained snapshot-body ownership, streaming/portable restore and offline administrative recovery controls.
  • Property-aware result-cache dependencies, snapshot-safe adjacency reuse and L4 cache visibility in the admin dashboard.
  • Diagnostic tracing for logical query, search and snapshot phases, plus regional workload, causal-read and recovery qualification tooling.
  • Recovery, latest-onboarding I/O and routing hardening. These changes do not remove the documented application retry, licensing or experimental-feature requirements.

Measured qualification and limits — #1261

The frozen pagination candidate before the metadata-only version bump completed a strict 10,800-second mixed workload on 2026-09-21. It used one SSD host, three voters, two nonvoting learners, three Rust/neo4rs backends and simulated 20/40 ms one-way learner delays. The dataset contained 334,770 tickets, 2,201,041 comments and 5,488,094 source edges. It was not a 1M-ticket test.

All 56,620 timed HTTP attempts succeeded and the independent result/order, cursor, ACL, causal-read and search checks passed. The final run retained host caches and included concurrent mutations and analytics; it did not inject faults.

Timed HTTP operationSamplesp90p99Maximum
Follow-up cursor pages4,605246.3 ms252.8 ms407.2 ms
Ticket comments4,757247.0 ms253.9 ms403.8 ms
Author comments5,8491,719.8 ms3,895.7 ms7,664.2 ms
Author aggregation3603,073.0 ms4,522.4 ms5,013.0 ms

These are nearest-lower sample quantiles, including regional delay; preflight and cleanup are excluded. The earlier initial smoke failed on first-touch author aggregation at the eight-second request budget. A subsequent green mixed run does not fix or waive that cold-path failure. Author-page tails also remain a performance follow-up. The isolated improved cursor case is not representative of every author page.

Reproducible evidence, exact binary hashes and failed attempts are recorded in repository docs/ordered-edge-pagination-1261.md and issue #1261. No real-WAN, cold-start, memory-limited or universal production approval follows from this version number or the three-hour result.

Upgrade boundary

All workspace crates inherit version 0.20.0. The ordered-edge feature introduces no new persistent index format. Intermediate changes since 0.17.0 include disposable cache-format changes; cached results are not authoritative database contents. Follow the first-customer checklist, preserve a verified recoverable backup and validate your own queries and driver behavior before rollout. Do not assume arbitrary mixed-version or downgrade safety from the unchanged edge-index format alone.

0.17.0 Release Notes

This documents the 0.17.0 release candidate. Publication of this manual is not a release tag, a merged-PR assertion or first-customer production approval.

0.17.0 optimizes initial fulltext index construction and reconstruction (#1261). It retains the existing posting-list storage format, analyzers, BM25 scoring and synchronous maintenance of subsequent document updates.

Grouped fulltext construction

Instead of loading and rewriting a growing posting list for every document, the builder stages document postings, sorts them and writes each complete term list once. Document encounter order is preserved within each list. Node and relationship indexes use the same builder; graph scan errors propagate rather than silently producing an incomplete index.

Sorting reuses PATINADB_SORTMERGE_RUN_BYTES (default 128 MiB) and temporary files under the process temporary directory (TMPDIR on Unix). This bounds the sort run buffer, not total process memory: merge readers, the largest posting list, serialization and database caches need additional RAM. Provide temporary disk space; ordinary completion, cancellation and errors remove scratch files, but a hard process kill can leave temporary directories behind.

Index creation remains synchronous and uses the existing atomic DDL boundary. No thread-pool setting, asynchronous update mode or on-disk migration is added. Existing indexes need not be rebuilt for compatibility.

Validation and scope

On 334,770 real Apache Jira summaries, standard analyzer, one summary property, local Ryzen 7 3800X/NVMe, the paired 2026-09-10 runs under #1261 measured:

Metric0.16.0 baseline0.17.0
Complete initial index build1727.563 s7.950 s
Peak build RSS876.578 MiB464.113 MiB
Allocated database directory after build1375772672 bytes477433856 bytes
Mixed 2 reads/s + 0.1 updates/s read p9074.563 ms66.816 ms

The measured build is 217.31 times faster; this is one complete build per implementation, not a hardware-independent guarantee. Warm Top-10/20 p90 across the tested forms is 2.173–63.868 ms. No search algorithm change is claimed.

Differential tests cover all four analyzers, in-memory and spilled construction, posting bytes, query scores, subsequent updates and deletion. A regression guard checks one final write per distinct term list. The Jira benchmark checks exact hit sets, independent single-term BM25, Top-10/20, update visibility and reopen. Measured results are recorded in docs/fulltext-benchmark-1261.md under #1261.

This is not a qualification of 10M tickets, large ticket bodies, WAN fan-out, high-rate writes or concurrent queries during initial construction. Subsequent updates still rewrite affected lists. See Full-Text Search.

0.16.0 Release Notes

0.16.0 optimizes the existing IVF-Flat vector implementation (#1283). It does not introduce Lucene, HNSW, a new persisted index format or quantization.

Vector-index construction

Initialization caches each training sample’s nearest selected-centroid score. Each newly selected centroid updates that cache; previously selected centroids are not rescored during every selection round. Parallel work computes per-sample scores and Lloyd assignments. Weighted selection and centroid accumulation keep their original order, preserving existing seeded results.

PATINADB_VECTOR_BUILD_THREADS controls the shared per-process training pool: positive integers are capped at 8; 1 selects serial execution. When unset or invalid, the default is available logical CPUs minus one, bounded to 1–8. The setting is read at first training use; restart to change it. Failure to create the pool logs a warning and falls back to serial execution. Backfill persistence remains ordered and single-writer; this setting does not parallelize all of Raft.

Training logs report initialization completion and each Lloyd iteration. Search feeds decoded candidates directly into top-k selection rather than first retaining all decoded candidate vectors. This removes one intermediate buffer; it does not promise constant total RAM, a fixed index cache budget or bounded materialization in every historical storage view.

Compatibility and validation

CALL db.awaitIndexes([timeoutSeconds]) now checks local index readiness, without output columns. Synchronous index maintenance normally needs no wait; pending deferred builds obey the timeout and query cancellation. It is not a replication barrier. See Procedures. The compatibility reference also explains deliberate static-label/type syntax, client-side grouping, unsupported index hints/scoped CALL, and supported scalar vector similarity.

Measured on the local Ryzen 7 3800X / NVMe fixture with 334,770 real Jira summary vectors (#1283, 2026-09-09): the complete 256D index build drops from 3429.950 s to 72.759 s (47.14 times faster), using eight training workers and unchanged index parameters. Peak build memory stays about 1.18 GiB. Warm loopback Bolt p90 is 22.886 ms for Top-10 and 23.402 ms for Top-20; sampled recall against the same exact oracle remains 96.719% / 94.844%. These are single-client measurements on this fixture, not a 10M-vector guarantee. The source-tree ledger docs/vector-benchmark-1283.md records hashes, commands, 512D results, RAM and the intentionally interrupted legacy 512D baseline.

  • Existing index definitions and similarity scores are unchanged.
  • Reference tests compare centroid bits for cosine/euclidean, different worker counts and seeds, duplicates, empty/identical inputs and cluster counts.
  • An operation-count test guards against reintroducing repeated initialization scoring. The optional timing benchmark compares the original implementation with the optimized one; wall-clock thresholds are not a flaky CI assertion.
  • vector_training benchmarks a retained real database and checks its trained definition against the existing reference without replacing the index.
  • The new openCypher Compatibility Reference documents supported, partial and unsupported forms. Its TCK inventory is explicitly dated to the reviewed 0.15.1 core revision, not a new 0.16.0 language-conformance certification.

These changes do not close the existing large-scale backup/restore, cold learner onboarding or 10M-vector qualification gaps. A version bump is not a production release approval. See Limitations.

patinaDB 0.15.0

All workspace crates share version 0.15.0. This manual covers the current revision; publishing documentation does not publish a binary or authorize a production rollout.

What changed since 0.13.0

  • Region-aware Bolt routing includes named nonvoting learners and explicit local-read policies. See Multi-Region Topology.
  • Scoped causal bookmarks fence reads against applied commits. A routed reader must catch up or refuse the read; routing alone does not guarantee freshness. See Bolt and Client Retry Contract.
  • The correctness/security audit hardens snapshot database reconciliation, transport trust, Bolt resource limits and derived-cache lifetime. See Limitations for boundaries that still apply.
  • Automated local release gates exercise idempotency, recovery, routing and impaired learner links. The Jira fan-out harness adds simultaneous admission under reads and updates. A local regression pass is not customer acceptance.

Current qualification: not production approved

The measurements below used the original relative gate. Subsequently, on 2026-09-08 (#1261), the user selected p90 <= 300 ms for explicit backup/fan-out profiles, with relative 3x increases reported as warnings. Old artifacts retain their original verdicts; this policy change is not a fresh qualification run.

The corrected-proxy local 1M-ticket run on 2026-09-08 (#1261) completed 60/60 successful samples per query family, with 2 reads/s and 0.1 updates/s during the mixed phase. Both learner logs show physical installation and subsequent history backfill completion. However, the unchanged whole-phase p90 gate fails:

QueryIdle p90Mixed p90Mixed / idle
Q110.547 ms79.391 ms7.528x
Q310.989 ms62.795 ms5.715x
Q417.728 ms95.477 ms5.386x
Q511.429 ms58.257 ms5.097x
Q710.768 ms49.430 ms4.591x
Q910.811 ms70.946 ms6.562x

The required limit is 3x each query’s idle p90. Q2/Q10 pass; only the previously accepted Q6/Q8 exceptions apply at this scale. Low absolute latency does not erase a failed relative budget. These are two 300-second phases on one shared host with synthetic learner links, not real WAN, isolated transfer latency, p99 qualification or a 6M/60M extrapolation. The run failed before final causal evidence was persisted; it does not establish a complete onboarding verdict. Source: #1261, docs/jira-fanout-2026-09-08.md in the source tree and its retained million2 raw baseline/mixed reports. The interference cause remains unisolated.

Cold onboarding at the target throughput, real-backend integration, real WAN, off-host restore and operating/retry contracts remain release obligations. See First Customer and Limitations.

Upgrade boundaries

The package-version bump itself does not alter storage or peer-protocol formats. Do not infer rolling mixed-version compatibility from package numbers: regional routing and causal identity changes have their own protocol/snapshot contracts. Use the production runbook, take and verify a backup, and qualify the exact candidate before upgrading a live cluster.

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 21001 — POST /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:21001 — POST /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.

Element identity (id() / elementId())

  • elementId(n) is the blessed, stable string handle for a node — the vertex’s UUID as a string. It is exactly what a driver stores and what you look nodes back up by:

    MATCH (n:Person) WHERE elementId(n) = $eid RETURN n
    
  • id(n) returns the same UUID (as a Uuid). A lookup by the UUID string resolves the node on every surface — WHERE id(n) = '<uuid>', WHERE elementId(n) = '<uuid>', and MATCH (n {__id: '<uuid>'}) — so a round-trip never silently returns zero rows. All three are served by an O(1) point lookup, not a scan.

    There is no integer node id. patinaDB has no Neo4j-style integer id, so WHERE id(n) = <number> (or a boolean) raises a clear error rather than silently matching nothing — compare against a string element id instead.

  • id is an ordinary property — patinaDB does not hijack it. You may store and query your own id freely, exactly as in Neo4j:

    CREATE (:User {id: '550e8400-e29b-41d4-a716-446655440000', name: 'ada'})
    MATCH  (u:User {id: '550e8400-e29b-41d4-a716-446655440000'}) RETURN u  // matches
    MATCH  (u:User) WHERE u.id = 42 RETURN u                               // a plain property
    

    The reserved property name __id is the internal element id: n.__id reads the node’s UUID and MATCH (n:Label {__id: <uuid>}) is the O(1) point lookup on it. Property names beginning with __ are reserved for patinaDB; writing __id — via a pattern property, SET n.__id = …, SET n = {…} / n += {…}, or bulk import — is rejected with a Neo.ClientError.Statement.ArgumentError and leaves the graph untouched.

  • id(r) / elementId(r) return a relationship’s canonical <start>:<end>:<type> string handle. Because an edge is that triple (the storage index keys on it), the handle is unique and stable. Fetch a relationship by it with a scan:

    MATCH ()-[r:KNEW]->() WHERE elementId(r) = $handle RETURN r
    

    A driver’s r.element_id over Bolt 5+ carries the identical handle. (The legacy Bolt integer id field is derived from a lossy 63-bit hash and is not round-trippable — always use element_id.) The model is id-less by design: it cannot store two parallel edges of the same type between the same pair of endpoints, so the handle is unique over everything it can represent.

Parallel edges: what actually happens

The “no parallel edges” rule above is a storage fact — the triple (start, end, type) is the edge’s identity. Unlike Neo4j, an edge here has no independent relationship identity of its own, so a second edge of the same type between the same two nodes has no separate place to live.

A CREATE that would collide with an existing edge is therefore rejected:

CREATE (a)-[:R {n: 2}]->(b)   // (a)-[:R {n: 1}]->(b) already exists

fails with Neo.ClientError.Schema.ConstraintValidationFailed. The rejection happens before anything is written: the existing edge keeps its properties (n: 1 above), the graph is left untouched, and no write counters are reported. There is no partial effect to clean up.

This is deliberate. CREATE is never MERGE semantics in Cypher, and the alternative — quietly overwriting the existing edge’s properties — loses data without saying so. If you want the upsert, say so explicitly with MERGE.

Bulk import paths (LOAD CSV, CSV/Parquet/Arrow import) are not covered by this rejection. A bulk re-import is often deliberately upsert-shaped, so those paths keep the overwrite behaviour.

Modelling consequence. If your domain genuinely has several relationships of the same kind between the same two nodes — a user commenting on the same ticket twice, two separate employments of one person at one company — an edge per occurrence cannot express it. Reify the occurrence as an intermediate node instead:

// NOT this — the second CREATE is rejected, so the second comment is lost:
//   (u:User)-[:COMMENTED]->(t:Ticket)

// This — one node per comment, edges stay unique:
CREATE (u:User)-[:WROTE]->(c:Comment {id: $commentId, body: $body, at: datetime()})
CREATE (c)-[:ON]->(t:Ticket)

A UNIQUE constraint on the intermediate node’s business key (:Comment(id) above) then gives you real duplicate protection on top — see Constraints.

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)

The on-disk tables, key layouts, inline labels and property storage are described in How Storage Works.

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 subset of Cypher, not complete Neo4j/openCypher parity. The openCypher Compatibility Reference is the feature-by-feature inventory, including unsupported syntax, function names and semantic differences. The openCypher Technology Compatibility Kit (TCK) is a prioritized gap report, not a strict regression gate or certification. This chapter provides an overview and usage examples.

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.

Relationship type alternatives in paths

A MATCH path such as (a)-[:ROAD|RAIL*0..3]->(b) accepts either listed relationship type at each hop, including routes mixing both types. Repeating a type name does not duplicate a route. Distinct routes remain distinct result rows unless the query requests DISTINCT; matching endpoints alone do not collapse them. A physical relationship cannot be reused within one matched path. A lower bound of zero permits the empty path where the endpoint constraints match.

The same type alternatives work with incoming and undirected patterns. CREATE and MERGE still require a single fixed relationship type and reject variable-length relationship creation.

Named shortest paths

MATCH p = shortestPath((a)-[:ROAD*]->(b)) selects one unweighted shortest path per endpoint pair. allShortestPaths selects every tied shortest path; it does not enumerate longer paths. When several shortest paths tie, shortestPath does not promise which one is chosen.

The supported named form contains one relationship segment, with a minimum length of zero or one and an optional maximum. Incoming, outgoing and undirected traversal and relationship-type alternatives are supported. A zero minimum permits an empty path for matching endpoints. Identical endpoints with a positive minimum raise an error. Disconnected pairs produce no path; OPTIONAL MATCH retains its null-row behavior. This zero-length behavior differs from Neo4j with its default dbms.cypher.forbid_shortestpath_common_nodes=true, which rejects common endpoints even when the minimum is zero.

Relationship-variable bindings, relationship property maps, multiple segments, and a minimum above one are rejected in this form. A WHERE attached to the shortest-path MATCH and another relationship pattern in that same MATCH clause are also rejected. Filtering after WITH p is supported: it filters paths already selected and does not search for a longer path satisfying the filter.

Shortest-path search uses the query’s execution-memory budget and cancellation checks. Search buffers and emitted path collections also observe PATINADB_MAX_AGG_ROWS. A refusal aborts the query; it does not return an incomplete set of tied shortest paths as a successful result. Result memory is conservatively retained for the query-result lifetime, so discarded intermediates can cause an early budget refusal.

Membership values

Relationship patterns can be Boolean operands in projections, for example RETURN NOT ((a)-[:LINK]->(b)) AS disconnected or RETURN ((a)-[:LINK]->(b)) AND true AS connected. Named pattern bindings must already exist in scope. A bare pattern is not a general scalar value: returning (a)-[:LINK]->(b) alone, assigning it directly, or passing it to size() is rejected. Use an explicit EXISTS { (a)-[:LINK]->(b) } for a standalone Boolean existence result. With a null endpoint there is no matching path, so the negated existence result is true. Scalar parentheses and list expressions keep their ordinary meaning.

item IN items and item NOT IN items compare the value bound to item. For example, a node tested against collect(node) remains an entity; it is not implicitly converted to its UUID. Scalar and list bindings likewise retain their types. Use an explicit property such as node.id, or node.__id for the internal UUID, when the list contains those values. Dotted property membership keeps its existing lookup and seek behavior; null membership retains three-valued logic.

The bare-binding parser correction in PR #1410 has focused and core regression coverage. It does not fix the existing identifier limitation for literal dots inside backticks. No query performance claim follows from this semantic correction.

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 ERROR — FAIL (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.
  • Aggregation — count (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 searched forms). A simple CASE value may be a complete Boolean expression, for example CASE n.active IS NULL WHEN true THEN .... Boolean values use the same equality and NULL behavior as other simple CASE values; CASE WHEN predicate THEN ... remains the searched form.
  • List comprehensions and pattern comprehensions.
  • Quantifiers — any / all / none / single, including aggregate and count(*) sources.
  • Scalar functions — toInteger, toString, size, labels, type, nodes, relationships, range, and many more.
  • Temporal functions — date, time, localtime, datetime, localdatetime, duration, plus namespaced calls (date.truncate, duration.between, datetime.fromepoch) with ISO-8601 week numbering.

datetime({epochMillis: value}) constructs an instant from integer Unix milliseconds, including negative values. For example, datetime({epochMillis: -1}).millisecond is 999. An optional timezone changes the local representation of that instant; calendar components can override it. A no-op override in an ambiguous daylight-saving hour retains the original offset. Invalid epoch types or competing temporal selectors raise an argument error; datetime(null) still returns null.

Epoch-map construction rejects instants or override years outside the supported calendar range rather than wrapping them. Named zones have an additional calendar-library range bound. A calendar override into a nonexistent local daylight-saving time raises an error; it does not shift the time forward as Neo4j does. This is not full temporal-range or override compatibility.

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

A missing sort property is NULL. A storage error while reading a required node or relationship sort property fails the query, for both full sorting and ORDER BY ... LIMIT. Empty inputs and LIMIT 0 do not require those reads.

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. SHOW INDEXES returns an indexes list of strings. Single-property entries remain bare property names and point indexes retain their POINT FOR … form. Each registered compound definition appears once, for example COMPOUND INDEX [`batch_lookup`] FOR (:`Batch`) ON (`run_id`, `id`). The field order is part of its identity; matching registered names are listed together. Unnamed definitions omit the name list. Identifiers are backtick quoted, with embedded backticks doubled. This inventory reports registered schema; it does not report a separate readiness or population state.

  • 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 also includes a Decisions: section after the physical-access details. Each entry names the decision point, chosen alternative, available row/cost estimates, reason and provenance. These are the selector’s existing estimates, not measured runtimes. Priority walks stop when their winner is known, so later alternatives may be absent.

PROFILE adds Decisions (executed): and an Executed operators (query thread): section. Operator numbers are preorder indices of the executed plan. rows=N counts successful rows actually pulled from that iterator; a LIMIT can leave an input only partly consumed. MatchScan also shows its entry estimate and q-error (the larger of estimate/actual and actual/estimate, replacing zero with one). An operator bypassed by a fast path is marked rows=unobserved (bypassed/fused). Worker internals and mutation/seeded paths that bypass the iterator are not per-operator measurements. The separately planned access tree is still labelled Planned access (before execution); use the executed section for row counts.

The audit retains at most 128 distinct decisions and coalesces repeats by point, subject and chosen alternative. repeats includes repeated seeded executions; the displayed estimates are from the first observation. Ordinary queries do not construct audit events or use a per-row counting adaptor.

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.

Planning note for filtered pages

For indexed keyset pagination, use a matching compound index and a qualified ordering property, for example:

CREATE INDEX tickets_by_project_id IF NOT EXISTS
FOR (ticket:Ticket) ON (ticket.project, ticket.id)

MATCH (ticket:Ticket {project: $project})
WHERE ticket.id > $after
RETURN ticket.id AS id, ticket.title AS title
ORDER BY ticket.id LIMIT $limit

Use separate parameterized query shapes when an optional filter/cursor is absent, rather than assuming ($after IS NULL OR ticket.id > $after) preserves a seek. ORDER BY id is valid syntax, but the #1261 million-ticket application diagnosis observed a separate sort for that projected alias while ORDER BY ticket.id selected the compound sort-covered path. Check EXPLAIN on the actual query; equivalent result semantics do not guarantee identical access plans. The local engineering evidence is docs/customer-blackbox-million-1261-2026-09-16.md.

For a required single-node match with equality predicates covering exactly a compound index’s fields, count(*) or count(ticket) can use agg.filtered_count_scan: it counts matching index entries rather than loading each node. This is still a scan of matching entries, not a precomputed counter. Optional matches, additional filters, secondary-label matches and unsupported value encodings retain the general execution path. EXPLAIN reports the actual choice. The snapshot transaction and numeric-equality semantics are unchanged.

A non-null scalar comparison on the ordered property can prove that missing values cannot match, avoiding a separate whole-label population check for that compound sort. Without such a predicate, NULL placement must still be preserved; first-page planning can therefore cost more than a bounded keyset page. These planning rules do not promise a fixed latency for arbitrary distributions.

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 partial subquery, expression and error-semantic compatibility. See the compatibility reference rather than using a historical TCK percentage as a current support contract. (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_created, relationships_created, properties_set, … in query summaries) are reported on both surfaces: as stats in the REST write response and as ResultSummary.counters over Bolt — see Bolt → Side-effect counters for which counters exist and which are deliberately never emitted.

openCypher Compatibility Reference

This is the support inventory for the 0.15.1 source tree, reviewed on 2026-09-09 at 6fb6d521. It describes the implemented language, not a claim of complete openCypher, Neo4j Cypher 5/25, GQL, APOC or GDS compatibility. Bolt connectivity and neo4j:// routing do not imply language compatibility.

Supported below means that the stated form is implemented; it does not mean that every combination, invalid input or error code matches Neo4j. Partial means that the boundary matters to application correctness. Unsupported means that applications must not use that form. Parsing successfully is not sufficient evidence of equivalent execution, especially for the subquery restrictions below.

Use this chapter for porting decisions, Cypher Support for examples, and Limitations for storage and operational differences.

Clauses and graph patterns

FeatureStatusSupported form and boundary
Node matchingSupportedMATCH (ticket:Ticket {key: $key}); anonymous nodes, multiple labels :Ticket:Open, comma-separated patterns and property maps. Labels and relationship types are static identifiers, not parameters.
Relationship matchingSupportedOutgoing ->, incoming <-, undirected -; relationship variables, properties and type alternatives [:LINK|BLOCKS].
Optional patternsSupportedOPTIONAL MATCH, including chained optional clauses. Its attached WHERE filters the optional side before null-extension; it is not an ordinary post-join filter.
Variable-length pathsSupported[:LINK*], *2, *1..3, *..3, *1.., *0..3; named paths route = (...). Resource/traversal limits still apply. Prefer explicit bounds.
Shortest pathsPartialshortestPath((first)-[:LINK*]->(last)) and allShortestPaths(...) are implemented for unweighted traversal between bound endpoints. allShortestPaths as an expression returns a list of paths; do not assume every Neo4j shortest-path query shape or cardinality is interchangeable.
Pattern predicatesSupportedWHERE (ticket)-[:LINK]->(other) and negation.
FilteringSupportedWHERE, parentheses, AND, OR, XOR, NOT; =, <>, !=, <, <=, >, >=, IN, NOT IN, IS NULL, IS NOT NULL; label tests on node/relationship values, including entity-valued list elements and projections (UUID values remain scalars); STARTS WITH, ENDS WITH, CONTAINS, =~. Regex behavior is not a promise of Java-regex compatibility.
ProjectionSupportedRETURN, aliases, RETURN *, DISTINCT; WITH projection, aggregation, filtering and subsequent query stages.
Ordering and paginationSupportedMultiple sort expressions, ASC/DESC and long spellings, SKIP, LIMIT; parameterized offsets/limits. NULLS FIRST/NULLS LAST is an additional accepted form. Without ORDER BY, result order is not an application contract. Use a unique tie-breaker for pagination.
Row sourcesSupportedUNWIND over lists, expressions or parameters, including lists of maps and concatenated or sliced sources such as UNWIND range(1, 2) + [3] AS x.
UnionsSupportedUNION removes duplicates; UNION ALL preserves them. This is not a claim of modern named-column UNION … BY NAME support.
InsertSupportedCREATE nodes and relationships, with property expressions. Repeated relationships have the storage restriction below.
UpsertSupportedMERGE, ON CREATE SET, ON MATCH SET, per incoming row; undirected matching. A merge is not permission to create duplicate constrained keys.
Property/label updatesSupportedSET ticket.prop = expression, SET ticket = map, SET ticket += map, SET ticket:Label; entity-valued map replacement/merge; REMOVE ticket.prop, label removal. Setting a property to null removes it.
DeletionSupportedDELETE, DETACH DELETE. Ordinary deletion cannot leave attached relationships dangling.
FOREACHSupportedBody accepts the full updating-clause inventory — CREATE, MERGE (with ON CREATE/ON MATCH SET), property SET and SET n:Label, REMOVE (property and label), DELETE/DETACH DELETE — applied in written order per loop item through the same constraint enforcement as the top-level clauses. Entity-valued list items (collected nodes, path elements) bind as real entities; a scalar DELETE target raises InvalidDelete. A nested FOREACH is not supported.
Planner inspectionSupported extensionEXPLAIN query and PROFILE query; their operator names/output are Patina-specific, not Neo4j plan objects.
Index hintsUnsupportedMATCH (ticket:Ticket) USING INDEX ticket:Ticket(key) …, including USING INDEX SEEK. Remove the hint; the planner chooses access paths automatically. Creating an index does not add hint syntax.
Dynamic labels / relationship typesUnsupportedMATCH (ticket:$(expression)), MATCH ()-[link:$(expression)]->(), dynamic CREATE, and SET/REMOVE ticket:$(expression). $() is not an alternative parameter spelling; labels/types must be static identifiers.

Why labels and types are static

Not supporting :$(expression) is a deliberate design decision, not an undocumented compatibility promise. A static label/type gives the planner a known target for label/type-specific access paths and eligible property-index seeks, rather than requiring runtime dispatch across varying labels/types. It does not guarantee an index seek for every query; indexes, predicates and planner decisions still matter. This is the design rationale, not a measured performance comparison against dynamic Cypher in another database.

For mixed-label/type input, group items in the client by their label/type and submit one static query template per group, using UNWIND $rows for values. Select templates from an application-controlled allowlist; never interpolate untrusted labels/types into query text. Grouping into separate commits changes atomicity; use an explicit transaction when the supported operations must commit together. This keeps runtime variability in parameter values, not graph schema identifiers.

Subqueries and procedures

FormStatusExact scope
CALL { … }SupportedCorrelated subquery using an importing WITH, for example WITH 7 AS outerValue CALL { WITH outerValue RETURN outerValue AS innerValue } RETURN innerValue. Read and write subqueries exist.
New variable-scope syntaxUnsupportedCALL (ticket) { … } and CALL () { … }. Use the importing-WITH form.
EXISTS { … }PartialA single pattern, optional MATCH, optional attached WHERE. Scalar/projection use is supported. Following WITH/RETURN clauses execute, including filtering, DISTINCT, aggregation and LIMIT. Other general inner-query syntax remains unsupported.
COUNT { … }Partial extensionCounts matches of a single pattern with optional MATCH and WHERE. Following WITH/RETURN clauses execute and COUNT returns the resulting row count. It is not a general Cypher 5 count-subquery implementation.
COLLECT { … }UnsupportedUse aggregation collect(expression) or an appropriate CALL subquery instead.
Procedure callSupportedCALL namespace.name(arguments) with explicit YIELD column [AS alias]; results can feed subsequent clauses. Only registered procedures exist; see Procedures.
Index-await procedureSupported since 0.16.0CALL db.awaitIndexes() or CALL db.awaitIndexes(300): succeeds without output columns once the local deferred-build marker is clear. Normal synchronous index maintenance needs no additional wait. Pending builds wait up to the timeout; storage errors and query deadlines propagate. Not a replica-catch-up barrier. See Procedures.
Parenthesis-free callPartialCALL namespace.name takes implicit query parameters; nothing may precede it. An explicit YIELD and following projection are allowed. It is not an in-query row-dependent call.
YIELD *PartialOnly a standalone procedure call, with no surrounding clauses, including no trailing RETURN. Use explicit field names inside a query.
Batched write subqueryPartial extensionCALL { … } IN TRANSACTIONS [OF n ROWS] [ON ERROR CONTINUE|BREAK|FAIL]; final clause only, default chunk size 1000 rows. Independent chunk commits, not one atomic statement. FAIL does not undo previously committed chunks. No IN CONCURRENT TRANSACTIONS or REPORT STATUS AS syntax.
User-defined proceduresPartial extensionRegistered read-only WASM procedures through CALL wasm.name(...). Despite CREATE FUNCTION naming, these are not scalar UDFs usable in RETURN name(...). No Java/Python/JavaScript procedure runtime.

For batching, file access, explicit Bolt transaction restrictions and partial commit handling, consult Bulk Loading, Cypher Support, and Bolt. Do not assume batching also bounds every outer-query sort, aggregation or row materialization.

Expressions and values

FeatureStatus / boundary
LiteralsNull, booleans, signed integers, floats/exponents, hexadecimal/octal integers, single/double-quoted strings, lists and maps.
Quoted namesEvery symbolic name — label, relationship type, property key, map key, variable, alias, parameter, procedure, index, constraint and function name — may be backtick-quoted: `K` and K are the same name, and a doubled backtick is one literal backtick (`a``b` is the name a`b). Results report the decoded name; SHOW INDEXES re-quotes compound definitions so they parse back. An empty quoted name is a syntax error, and names beginning with `pdb_ are reserved for internal bindings. A quoted name containing a . cannot be used as a property-path segment (n.`a.b`).
Parameters$name and positional-style $0; values can be lists/maps. Parameters are not textual substitution for labels, property names or entire query fragments.
Arithmetic+, -, *, /, %, ^, unary minus and parentheses. String/list concatenation is supported through expression evaluation.
Case expressionsSimple CASE value WHEN …, including complete Boolean values such as CASE n.x IS NULL WHEN true …, and searched CASE WHEN predicate …; optional ELSE. A NULL simple-CASE value does not match any WHEN value, including NULL.
List operationsIndexing including negative indexes, slices with omitted bounds, comprehensions [item IN source WHERE predicate | expression], reduce(accumulator = initial, item IN source | expression).
Map accessMap literals, map.field, nested access, expression indexing.
Map projectionPartial: ticket {.key, .summary}. Not the general Neo4j projection language: ticket {.*}, computed entries ticket {extra: expression}, and mixed variable/literal selectors are not supported.
Pattern comprehension[(first)-[:LINK]->(other) WHERE predicate | expression]. Entity-list and nesting edge cases are not fully TCK-conformant.
Quantifiersany, all, none, single over a list with WHERE. Scalar-list forms exist; do not assume full entity-list/edge-case compatibility.
Aggregationcount(*), count, sum, avg, min, max, collect, stDev, stDevP, percentileCont, percentileDisc; DISTINCT inside aggregates. Grouping is implicit in non-aggregate projections; no SQL GROUP BY/HAVING clauses.
Aggregate expressionsNested functions/arithmetic around aggregate results are implemented. This is not a guarantee for every implicit-grouping or aggregate-ordering edge case.

Function arguments support postfix list slices/indexes and map-field access: RETURN size([1,2,3][1..]) returns 2, and RETURN abs({nested: {value:-7}}.nested.value) returns 7. Omitted and negative slice bounds use the same expression semantics as an intermediate WITH value. This does not imply full Neo4j expression compatibility in every grammar position; test the exact expression when porting a query.

Built-in scalar function inventory

These are the function names registered by the core grammar/evaluator. A name here does not imply every Neo4j overload, coercion or invalid-input error is implemented. Unknown scalar names are not a plugin discovery mechanism.

FamilyNames
ConversiontoString, toInteger, toFloat, toBoolean, toStringOrNull, toIntegerOrNull, toFloatOrNull, toBooleanOrNull
StringstoLower, toUpper, trim, lTrim, rTrim, split, replace, substring, left, right, reverse
Numericabs, ceil, floor, round, sqrt, sign, log, exp, sin, cos, tan, atan, asin, acos, atan2, pi, degrees, radians, rand
Lists/nullssize, isEmpty, head, tail, last, range, reverse, coalesce
Graph valuesid, elementId, labels, keys, properties, type, nodes, relationships, length, startNode, endNode
Temporal constructorsdate, time, localtime, datetime, localdatetime, duration, timestamp
Temporal namespacesdate/time/localtime/datetime/localdatetime.truncate; their .statement, .transaction, .realtime forms; duration.between, .inSeconds, .inMinutes, .inHours, .inDays, .inMonths; datetime/localdatetime.fromepoch, .fromepochmillis
Spatialpoint, distance, withinBBox, point.distance, point.withinBBox; extensions point.distance.ellipsoidal, polygon, within, contains, intersects, polygon.within, polygon.contains, polygon.intersects, polygon.withinBBox — see Spatial for types and semantics
Vectorvector.similarity.cosine, vector.similarity.euclidean — see Vector Search for score definitions

range(start, end [, step]) includes reachable endpoints and stops before the next step would overflow a signed 64-bit integer. A zero step is rejected; materialized results remain subject to the configured aggregate-row limit.

Not present as core scalar functions: randomUUID, log10, e, cot, haversin, char_length, character_length, normalize, valueType, toIntegerList/toFloatList/toBooleanList/toStringList, and arbitrary apoc.* functions. This is an explicit inventory, not “and all other Neo4j functions.” Named procedure equivalents, where documented, use CALL instead.

Vector similarity: supported without an index

RETURN vector.similarity.cosine([1,0], [0,1]) AS score returns 0.5. This is normalized cosine (1 + raw cosine) / 2, not raw cosine: identical nonzero vectors score 1, orthogonal vectors 0.5, opposite vectors 0. vector.similarity.euclidean([0,0], [3,4]) returns approximately 1/26, using 1 / (1 + squared distance).

Both are scalar functions over equal-length numeric lists, not CALL procedures and not nearest-neighbour searches. They require no vector index. Evaluation converts components to f32; null arguments propagate null, dimension mismatches and non-numeric components error. This is not a guarantee of all Neo4j invalid-vector edge cases. Use db.index.vector.queryNodes for indexed Top-k search; see Vector Search.

Important semantic differences

  • Expression membership: IN and NOT IN accept expression-valued left operands, for example split(node.issue_key, '-')[0] IN $projects after a procedure’s WITH node, score. This does not make arbitrary predicates index-seekable; simple property membership retains its existing planner path.
  • Grouped predicates: a grouped WHERE predicate followed by = true is not generally accepted; use the predicate directly in WHERE.
  • Identity: nodes use UUIDs; elementId(node) is the portable string handle. id(node) is not a Neo4j integer ID. Relationship identity is the endpoint/type triple. Two relationships of the same type between identical endpoints cannot coexist; Cypher duplicate creation errors, while bulk import has a separate overwrite contract. See Data Model.
  • Synthetic label: an otherwise unlabelled stored node uses the primary label Node. This is not a fully label-free Neo4j storage model; the TCK comparator normalizes this difference, so TCK results do not disprove it.
  • Strings: the scalar evaluator’s size(string) counts UTF-8 bytes, not Unicode characters. For example, size('é') is 2. Other string operations need not use the same unit; test non-ASCII application text explicitly.
  • Nulls: the scalar evaluator returns true for isEmpty(null), not null. Do not use it as a drop-in replacement for IS NULL. Null left operands in membership tests remain unknown even for an empty list; consequently WHERE null NOT IN [] does not retain the row.
  • Temporal clocks: .statement(), .transaction() and .realtime() route to the same current-time constructor family. They do not establish Neo4j’s distinct statement/transaction clock lifetimes. Extended years, timezone serialization and very large durations also need compatibility testing.
  • Numeric errors: integer division/modulo by zero and checked integer arithmetic overflow raise arithmetic errors; floating-point operations can produce infinity/NaN. Integer/float equivalence for grouping and DISTINCT is documented in Value semantics.
  • Error compatibility: not every invalid argument, deleted-entity access, procedure signature or semantic error has the same validation phase/status as Neo4j. A successful syntax check is not a compatibility certificate.

Schema, administration and non-openCypher extensions

These features are useful, but are separate from an openCypher language claim.

FeatureSupported scope
Property indexesLegacy CREATE INDEX ON :Label(prop) and node CREATE [RANGE] INDEX [name] [IF NOT EXISTS] FOR (node:Label) ON (node.prop); compound fields, drop/show. TEXT, LOOKUP, and explicitly named BTREE index types are rejected.
ConstraintsNode and relationship uniqueness, existence, key and property-type constraints; modern FOR … REQUIRE, legacy ON … ASSERT, and documented shorthand. Bare composite uniqueness without existence and LIST<T> refinement are not supported. See Constraints.
Specialized indexesFull-text, vector, point and edge-sorted indexes with their documented DDL. Options and implementation differ from Neo4j; e.g. accepting an HNSW option does not turn the vector index into HNSW.
CSV importLOAD CSV [WITH HEADERS] FROM 'path' AS row [FIELDTERMINATOR 'c']; local files only, no HTTP(S). Server reads require admin authorization and configured file allowlisting.
HistoryEngrams, diffs, AS OF and history procedures are Patina extensions, not openCypher.
Maintenance status (unreleased)Server-only SHOW JOBS lists local tag-snapshot jobs for the selected database; admin required. No YIELD, WHERE, ORDER BY or cluster aggregation. See Maintenance Jobs.
Databases/auth/routingDocumented server-side database/user/role commands and routing procedures. Not the full Neo4j administration language; selecting a database through Bolt/REST is distinct from unsupported query USE database.
Algorithms/proceduresOnly the built-in procedure catalog and registered WASM procedures. Not the complete APOC or Neo4j GDS libraries.

Explicit non-goals of this subset

  • Modern quantified path patterns/relationships and selectors such as SHORTEST k, ALL SHORTEST, ANY, ACYCLIC or TRAIL in their newer Cypher/GQL syntax; use the documented legacy patterns instead.
  • Dynamic-label/type syntax such as :$(expression); general label expressions such as (node:Ticket&Open) are not the supported :Ticket:Open form.
  • INSERT, LET, FILTER, FINISH, NEXT, query USE, SQL window functions, SQL GROUP BY/HAVING, and arbitrary multi-statement scripts in one query.
  • Triggers, unrestricted server-side scripting, writable WASM UDFs, and a Neo4j plugin ABI.

Evidence and maintenance

The implementation inventory is grounded in these source files:

  • crates/patinadb/src/cypher/cypher.pest: accepted syntax and function names.
  • cypher/parse_query.rs, parse_expr.rs, parse_ddl.rs: validation and lowering boundaries; grammar acceptance alone is not the support decision.
  • plan/eval/scalars.rs, plan/eval/shortest_path.rs, plan/exec/expr_eval.rs: expression behavior and the semantic differences called out above.
  • execution/in_transactions.rs and the server dispatchers: batching versus ordinary statement/transaction behavior.
  • crates/patinadb/tests/manual_cypher_contract.rs: executable examples and negative controls for selected documented unsupported forms.
  • crates/patinadb/tests/opencypher_tck.rs: broader conformance inventory.

The TCK runner is informational, not a strict all-green gate. Its comparison normalizes synthetic labels and some value representations; identity correlation across cells is not fully modeled. One pinned Gherkin parsing limitation also prevents a feature’s scenarios from running. Such scenarios must be counted as not passing, not silently excluded. A fresh TCK run still cannot replace this feature-by-feature contract or tests of the backend’s exact queries.

The 2026-09-09 run on the revision above reports 3755 passed, 113 executed failures, and 29 never-run scenarios: 3755 of 3897, with 142 not passing (#858 inventory). The process exits successfully even with those failures. This replaces the older 3754-pass inventory; it is not an all-green result.

Recheck after language changes:

cargo test --locked --release -p patinadb --test manual_cypher_contract
cargo test --locked --release -p patinadb --test opencypher_tck

Archive the complete TCK output, source revision and never-run scenario count. Do not publish an old pass percentage as the current release’s certification.

Counting independent optional branches

For supported independent one-hop OPTIONAL MATCH branches, count(*) can count their combinations without retaining the combined rows. Empty optional branches still contribute one null-extended row. This path can complete when the count exceeds the materialized-row limit; queries that retain those rows still use the configured limit. EXPLAIN/PROFILE identify this execution as agg.independent_optional_count. Other optional shapes retain normal execution.

Bulk Loading & Import

patinaDB supports offline CLI import and online bulk loading. The three online paths below are versioned (recorded in the engram log) and, on a cluster, replicated. Offline CLI import writes the datastore directly without engrams.

PathWhereEngramsMemoryBest for
patinadb importOffline CLI, database stoppedNo engramsBounded batches / sorted spoolsInitial CSV / columnar load; history and replication require separate initialization.
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.

Offline CLI import

patinadb import loads node and relationship CSV files into a database while it is offline. This direct datastore operation does not create import engrams or establish a replicated history baseline. Do not use it against a running server or treat a successful current-graph read as proof of history or replica recovery. Node headers identify :ID, :LABEL, and property columns; relationship headers identify :START_ID, :END_ID, :TYPE, and property columns. A property header may declare a scalar type (name:int, active:boolean, score:double) or one of the typed-list forms below. CSV quoting follows the CSV reader’s standard quoting rules, so a JSON array cell containing commas must itself be CSV-quoted, for example:

:ID,:LABEL,tags:string[]
u1,Person,"[""red"",""blue, green""]"

For typed-list properties, the cell contains a JSON array:

Header suffixesJSON element type
int[], long[], short[], byte[]Integer
float[], double[]Number
bool[], boolean[]Boolean
string[], char[]String

An empty cell means the property is absent. [] stores an empty list, while [""] stores a one-element list containing the empty string. JSON null elements are allowed. Array order and duplicate elements are retained; string contents, including leading or trailing whitespace, are preserved. The cell must be a JSON array whose non-null elements match the declared type. Nested arrays, objects, malformed JSON, and values of another element type are rejected.

Typed-list declarations apply to CSV import; typed Arrow List columns remain unsupported. This does not establish a CSV/Arrow export-import round-trip contract.

Scalar string properties (string, char, an untyped header, or an unrecognised type suffix) preserve the CSV cell exactly, including leading and trailing whitespace. A zero-length cell means the property is absent; a whitespace-only cell is a present string containing that whitespace. Earlier importer behavior trimmed scalar strings and treated whitespace-only cells as absent. Re-importing such files with this behavior can therefore change stored scalar string values or add a previously absent whitespace-only property. This is an intentional migration behavior; scalar numeric, Boolean, date, and datetime parsing retains its prior surrounding-whitespace handling.

For a :LABEL cell, semicolon-separated labels form the complete label set: the first is primary and later unique labels are secondary. Whitespace around label names is ignored; empty names (including A;;B or A;) are rejected. A later row for the same ID replaces the previous set; a single primary label clears existing secondary labels. This also applies to Arrow/Parquet label columns. Replacement does not imply deletion of omitted properties.

The importer retains its existing durable batch and interrupted-import recovery contracts. It does not make an entire multi-file import rollback-atomic.

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.

Correction (issue #1060, measured 2026-08-21): the O(rows) figure above holds for the plain example shown — a CREATE with no MATCH at all. The equally ordinary foreign-key-lookup import idiom —

LOAD CSV WITH HEADERS FROM 'file:///data/orders.csv' AS row
MATCH (c:Customer {id: toInteger(row.customer_id)})
CREATE (c)-[:PLACED]->(:Order {id: toInteger(row.order_id)})

— is not O(rows). Before the #1060 fix, LOAD CSV could never seed the planner’s index-seek restructure (issue #429/#367) the way a top-level UNWIND can, so MATCH (c:Customer {id: …}) fell back to a full label scan over every :Customer vertex, repeated once per CSV row — O(R · rows) where R is the distinct-customer count, not O(rows). Measured on a hub-with-N-leaves fixture (R = distinct parents, N = rows per parent): the real LOAD CSV path cost the same order of magnitude, ~3.9 GB peak RSS, as a synthetic double-UNWIND cross-join at R = 25, N = 20 000 — see crates/patinadb-bench/examples/existence_isolate_probe.rs (modes crossjoin_build / loadcsv_build) for the harness and crates/patinadb/src/execution/load_csv_tests.rs’s loadcsv_seed_restructure_matches_unoptimised_reference for the fix’s differential proof.

The fix makes LOAD CSV a seed candidate like UNWIND, so the shape above now seeks when the compared value is a bare CSV field (row.customer_id with Customer.id also stored as a string, no coercion).

Correction (issue #1060 follow-up 2, 2026-08-22): the paragraph above, as first written, said the coerced form (toInteger(row.customer_id)) — the exact shape in the example at the top of this section — stayed a “known, separate, still-open limitation”. That is no longer true. A second #1060 follow-up taught the binding_seek mechanism (#429/#367) to seek through a CypherLit::Func value too, for a small, deliberately narrow allowlist of pure coercions: toInteger, toFloat, toString, toBoolean — exactly the four this manual already recommends for coercing a CSV cell (see “Cells are strings” above). So the foreign-key example at the top of this section, written exactly as shown, now seeks. The residual limitation is narrower than before, not gone: a coercion or transform OUTSIDE that four-function allowlist (toLower, abs, a +/arithmetic expression, …) still does not seed a seek, even where the function itself is pure — the allowlist is deliberately kept small (each addition is its own auditable decision, not “obviously fine, might as well”; see plan::expr::SEEK_SAFE_SCALAR_FUNCS’s doc comment for the reasoning) — and a value-dependent expression referencing a binding the match pattern itself introduces (not yet bound at seek time) still declines safely rather than seeking a wrong key. Gate: execution::in_seek_and_compound_range_tests:: unwind_seed_func_allowlist_each_coercion_seeks_and_matches_reference (all four coercions) and execution::load_csv_tests:: loadcsv_seed_func_tointeger_coercion_matches_unoptimised_reference (the real LOAD CSV path, this exact idiom).

CALL { … } IN TRANSACTIONS (below) bounds peak memory to one chunk regardless of whether a given write shape seeks — do not read chunking as a substitute for the seek when a shape falls outside the allowlist above.

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 ERROR — FAIL (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 server → POST /mgmt/upsert/upsert-edges/upsert-graph (fastest, index-accelerated match-or-create, chunkable).
  • Live ingest into a running / clustered database from a streamed CSV → LOAD 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.

Deferred index-build memory (#1398)

Index rebuilding externally merges all sorted runs before filling the index. It limits each spill run by both key count (PATINADB_INDEX_BUILD_SORT_KEYS, default 8,000,000) and retained key allocation bytes (PATINADB_INDEX_BUILD_SORT_BYTES, default 67,108,864 = 64 MiB). The byte accounting includes each key’s allocated capacity and vector descriptors and sequence bookkeeping. Both limits must admit a key into the current run. Zero or invalid values use the defaults. This applies to vertex/edge value, compound and edge-sorted rebuilds.

These are run-buffer limits, not a total process-memory limit. Allocator overhead, spare vector capacity, backend cache/transactions and other import buffers consume additional memory. A single key larger than the byte budget is flushed on its own; it is not rejected or silently omitted. The existing pending-build marker and terminal durability barrier remain unchanged. Size the total process/cgroup budget separately, including file cache, and compare finalization as well as insertion time.

Sorted-spool filling has a separate pair of limits: PATINADB_SORTMERGE_FILL_BATCH (default 200,000 entries) and PATINADB_SORTMERGE_FILL_BYTES (default 67,108,864 = 64 MiB of retained key/value allocations and descriptors). The fill stops a batch when either limit is reached; a single oversized entry is applied alone. Zero or invalid byte values use the default. This bounds the sorted consumer batch, not the producer’s external-sort spools (PATINADB_SORTMERGE_RUN_BYTES), the entire import, or all snapshot buffers. The shared sorted fill also serves snapshot installation; its surrounding atomic installation scope and crash-recovery markers are unchanged.

The offline importer no longer switches between inline and deferred loading. All imports use sorted mutation spools, including re-imports and skipped input reference checks. Existing stores apply bounded sorted chunks with incremental value, compound and edge-sorted index maintenance. Each chunk preserves properties absent from its input and removes superseded index keys before its terminal durability barrier; it does not rebuild unrelated indexes. Fresh whole-load builds retain their pending marker and derive indexes from final primary values when repeated input IDs prevent trusting precomputed value-index keys. The retired PATINADB_IMPORT_SORTMERGE and PATINADB_DEFERRED_INDEX_MIN_ROWS selectors no longer choose an alternative implementation. Index rebuild scratch files use private temporary directories under TMPDIR; size that disk for spilled keys.

Incremental delta transactions

Small input buffers do not necessarily imply small redb transactions. Updating scattered keys can modify many existing B-tree pages. When those dirty pages outgrow the write cache, early writeback can add I/O before the final durability barrier. Sorting the keys alone does not prevent this.

Existing-store sorted deltas therefore also use an estimated-work budget. Automatic sizing uses 1/32 of the configured redb cache, capped at 64 MiB, with a minimum charge of 16 KiB per mutation. This is a heuristic, not a bound on dirty pages, RSS or I/O, and does not promise faster imports for every workload. Fresh dense index construction and snapshot filling use their fill limits. Direct snapshot buffers also obey PATINADB_SORTMERGE_FILL_BYTES: a small record count can still retain large property values. This includes historical reconstruction for background tag snapshots, not just learner installation. Smaller delta chunks keep the same deferred-commit and terminal-sync contract; they do not make partially applied imports a supported recovery state.

PATINADB_SORTMERGE_DELTA_WORK_BYTES overrides this heuristic with a positive integer byte budget. Leave it unset initially. Reduce it when profiling shows write-cache churn during incremental import; raise it only when matched tests show that extra small transactions cost more than they save. The ordinary PATINADB_SORTMERGE_FILL_BYTES and PATINADB_SORTMERGE_FILL_BATCH caps remain independent upper limits. Raising the work budget does not bypass them.

For example, a configured 128-MiB redb cache selects a 4-MiB work budget. With the minimum charge, that admits at most 256 mutations per transaction; wider keys or values reduce the count. This is the policy’s arithmetic, not a measured optimal batch size. Set environment overrides before opening the database.

Compare the same database image, input and total RAM limit in both arms. Include terminal sync in wall time, check contents and indexes after reopen, and record CPU time, actual read/write bytes and I/O pressure. A lower byte count alone is not a latency improvement. Leave RAM for the OS page cache and other database buffers; enlarging redb’s cache inside an unchanged cgroup can create pressure elsewhere. Neither tuning knob replaces the process memory limit.

Bulk Upsert (match-or-create)

Development correction (#1320): the constraint checks and entity-complete graph-chunk boundaries described below are being qualified after0.21.0. Released0.21.0 bulk resolvers can bypass schema constraints; do not assume that a successful bulk request proves constraint validity on that revision.

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). With the default conflict policy, an ambiguous match rejects atomic resolution before graph changes. Chunked loads and lost responses have different recovery boundaries below.

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 is a target number of resolved graph operations rather than input rows. A boundary extends to keep all changes to one entity together, including creation and its required properties. The entry can therefore exceed that target; the server’s entry-size limit still applies.

An ambiguous relationship endpoint fails explicitly. Node-file imports also offer the explicit on_conflict policies described below; none silently picks one arbitrary matching node.

Bulk resolution checks the final touched entities with the same uniqueness, required-property and property-type rules as Cypher writes, including secondary labels and relationship constraints. Validation uses disposable state, not partially applied live writes. Each committed chunk must also be valid on its own; rejecting a later chunk does not undo earlier committed chunks.

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}'

Rows whose keys an earlier chunk already created are matched, not duplicated. Every committed chunk must independently satisfy the schema constraints. Consequently, a whole-batch change such as exchanging two unique values may be valid atomically but invalid at an intermediate chunk boundary. The load remains revertable as one unit via its pre-load tag; chunking does not promise that every input accepted atomically will also succeed chunkwise.

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 using the pre-load HEAD/tag or reconcile the committed prefix. An error response need not contain the successful response’s revert hint. Without chunk_rows, a validation rejection before proposal leaves the graph untouched; use the default when the whole load must be atomic and fits in memory.

Constraint validation may briefly wait for a complete native read snapshot while another publication finishes. This wait consumes the request’s existing deadline; it does not extend the timeout, repeat committed chunks, or skip constraints. Embedded callers without a deadline receive the publication refusal immediately rather than waiting indefinitely.

A timeout or dropped connection is not evidence of rollback, even for an atomic request. The server may have committed the batch without its response reaching the caller. Persist the intended keys/values, check authoritative committed state with the appropriate causal barrier, and reconcile before retrying. Do not treat an initially absent write as proof that an in-flight request can no longer commit. Upserts do not automatically provide an unbounded exactly-once guarantee for arbitrary retry sequences.

/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.

Treat pre_load_tag as an opaque returned identifier, not a timestamp to parse or construct. The #1323 fix (development, not yet released) derives new marker names from the exact pre-load Engram; older builds used seconds and could reuse a tag for different loads in the same second. Existing tags are not rewritten. On an older build, retain the explicit pre_load ID rather than trusting a shared generated tag to keep its earlier binding. The fix also covers offline CLI node, relationship and graph upsert markers. Marker prefixes remain intact; the suffix is not an API for extracting wall-clock time.

Rollback retention is an operational policy

Pre-load tags pin history snapshots. Frequent tiny management upserts therefore accumulate rollback artifacts; the snapshot cache setting is not a total budget for tagged history. Prefer ordinary Bolt writes for individual ticket updates and group nightly synchronization into a managed load (chunked when necessary). Retain each returned marker until the load is reconciled and its rollback window expires. Then an administrator can DROP TAG <returned_tag> if no other workflow still needs it; never blindly delete snapshot files. Dropping the last tag referencing a snapshot unpins it, but is not a promise of immediate disk reclamation. Monitor allocated disk and preserve restore/maintenance headroom.

The #1261 two-hour 4,096-ticket management-load drill retained 755 snapshot files occupying 1,163,825,152 allocated bytes on one source node, versus 10,862,592 allocated bytes for its live graph file. This is a fixture-specific warning, not an extrapolation to larger databases; see the backup qualification report.

Snapshot admission between graph chunks

Before proposing each resolved graph chunk, the server validates its constraints against a native snapshot. If a concurrent publication temporarily prevents a consistent snapshot, this read-only validation can wait within the load’s existing query deadline. Each attempt uses that same deadline; it is not reset for each chunk. With no configured deadline, snapshot refusal returns immediately. Constraint violations and write/commit errors are not retried by this handling.

This wait never repeats an already submitted chunk and does not make the whole load atomic. A terminal failure can still leave earlier chunks applied. It does not carry the non-chunked write_outcome: "not_submitted" receipt, and a client must not blindly replay the whole request. No new environment setting or timeout increase is introduced (#1483).

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

Schema introspection (SHOW CONSTRAINTS, SHOW INDEXES, SHOW POINT INDEXES, SHOW EDGE SORTED INDEXES and SHOW FUNCTIONS) is read-only, including when query memory admission is enabled. Creating or dropping schema objects still uses the replicated write path in server mode. A client timeout does not prove that a schema change was rolled back: inspect the resulting catalog before deciding how to proceed.

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. Eligible ordered pages use this index to select candidates instead of starting with an unordered traversal. Residual filters, additional hops and secondary ordering still require work; an API page size is not a fixed bound on candidates read.

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

Fast-path eligibility is narrower than valid Cypher syntax. Besides the basic single-key shape, RFC 0035 adds direct-property secondary ordering, deterministic residual filters and fixed-hop continuation for a single resolved anchor. Identity WITH target projections can retain this access path. Computed sort expressions and renamed sort bindings are not covered by this extension. Keep a stable tie-breaker where your pagination contract requires one; do not remove it merely to obtain an index hit. Verify the entire query with EXPLAIN, including filters, cursor and WITH clauses.

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']

This single-value example assumes distinct creation times. For duplicate timestamps, use the stable two-key cursor below; a strict timestamp-only cursor would skip remaining rows with the boundary timestamp.

Stable cursors, filters and additional hops

Use a unique tie-breaker and carry both ordered values from the last row:

This recipe assumes non-null cursor fields and uniqueness across result rows, not merely target vertices. If a join produces several rows for one target, its identifier alone cannot distinguish those rows at a page boundary. Include the required continuation identity in the application’s ordering/cursor, or deliberately deduplicate before pagination (which may select the fallback plan). Nullable cursor fields need explicit null-aware predicates; ordinary =, < and > comparisons to null do not match null rows.

MATCH (author:Author {author_id:$author})
      <-[:AUTHORED_BY]-(comment:Comment)<-[:HAS_COMMENT]-(ticket:Ticket)
WHERE split(ticket.issue_key, '-')[0] IN $allowed
  AND (comment.created_at < $after_time
       OR (comment.created_at = $after_time AND comment.comment_id > $after_id))
WITH comment
ORDER BY comment.created_at DESC, comment.comment_id ASC
LIMIT $limit
RETURN comment.comment_id, comment.created_at, comment.body

The corresponding existing-format index is:

CREATE EDGE SORTED INDEX
FOR (comment:Comment)<-[:AUTHORED_BY]-() ON comment.created_at

The first page omits the cursor predicate. When the complete shape is eligible, EXPLAIN reports limit.edge_sorted_topk. The reader seeks using a proven necessary primary-key bound, keeps the boundary group, and still evaluates the original cursor and permission predicates. Secondary ordering is resolved inside complete primary groups. Only matching rows count toward SKIP/LIMIT; multiple matching ticket continuations retain their Cypher row multiplicity.

The extension currently admits a uniform primary value domain, across the indexed target label/property, of strings, integers, booleans or legacy timestamps, with missing properties handled by ordinary null ordering. Mixed primary domains and unsupported types fall back. Temporal Cypher values are distinct from legacy timestamps; this extension does not add multikey temporal acceleration. Optional source matches, variable-length source paths, DISTINCT before the page, unsupported expressions and insufficient secondary-label coverage also retain ordinary execution. Optional hydration after the page is separate and can be used without changing the inner page’s eligibility.

Large primary tie groups and restrictive permission filters can still require many reads. A secondary key is not physically stored in this single-property index, and no universal constant-time or fixed-read-count guarantee is made. The index and residual traversal use the same query snapshot, but independent page requests do not share a frozen snapshot: concurrent changes to ordering keys can move rows across pages. Use a supported fixed historical view when the application needs a frozen feed.

The RFC 0035 extension describes the development build, not a released-version guarantee. Component tests cover the unchanged Jira-shaped request, planner selection, duplicate continuations and pinned snapshots. A three-hour mixed regional simulation passed on the frozen development build (#1261, 2026-09-21); first-touch aggregation and long-tail filtered author pages remain performance limitations. This is not cold-start, real-WAN or production approval, nor a fixed latency guarantee per page.

Pages across an anchor frontier

A read-only query can build a frontier through earlier hops or UNWIND, then page over a final directed, fixed-length relationship to indexed targets:

MATCH (root:Account {id:$id})-[:FOLLOWS*1..2]->(author:Account)
WITH collect(DISTINCT author) AS authors
UNWIND authors AS author
MATCH (author)<-[:AUTHORED_BY]-(post:Post)
WHERE post.created_at < $before
RETURN post.id AS id, post.created_at AS created
ORDER BY created DESC, id ASC
SKIP $skip LIMIT $limit

Declare CREATE EDGE SORTED INDEX FOR (p:Post)<-[:AUTHORED_BY]-() ON p.created_at to make the final-hop index available. A regular property range index does not supply these per-anchor streams. Eligible plans report limit.edge_sorted_topk. The executor merges the streams, retains duplicate frontier rows and resolves complete primary ties before stopping. Only matching rows consume the page.

This frontier path currently supports uniform integer, boolean or legacy timestamp primary values, including missing values. Strings, floats, mixed primary types, secondary-only target label coverage and unsupported final-hop shapes retain ordinary execution. Earlier traversal still runs normally; a large frontier, large ties or restrictive residual filters can require substantial work. Index creation and maintenance have their ordinary costs. Independent page requests still observe independent snapshots.

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. Different primary sort properties or relationship types need separate definitions. Supported secondary sort keys do not require another index, but their tie-group work remains at read time.

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.

How the Query Planner Works

This chapter is the structured reference for the patinaDB query planner. It is written for application developers and operators who write Cypher and read EXPLAIN and PROFILE. It explains which stage makes each decision, what inputs that stage uses, and which settings change the outcome. With it you should be able to predict the plan for a query and explain it.

Query Planning & Performance keeps the detailed, feature-by-feature notes. This chapter links to it where that chapter has more detail.

Two rules hold throughout:

  • Plans never change results. Every alternative described here returns the same rows. Row order is the same wherever ORDER BY defines it. Without ORDER BY, row order is unspecified and can differ between plans and between cluster nodes. Statistics only choose plans; they never filter rows, and they are never replicated. Different access paths can visit different records, so which storage error a damaged record raises, and when, can depend on the plan.
  • The RFC 0042 cost model and operators are on by default. Since 2026-10-17 the cost model (join), factorized execution and the auto modes of worst-case optimal joins and the CSR adjacency cache are the shipped defaults (#1442). The reference planner, with heuristic rules over cardinality estimates, remains: it decides wherever the cost model declines or keeps it by margin, and setting all four switches to off restores it exactly (see Specialised operators and switches).

The pipeline at a glance

A read query passes through these stages, in this order:

StageWhat happensWhen
ParseCypher text becomes an AST. A parse cache holds up to 1,024 parsed queries.per text
Literal normalizationLiterals become internal parameters ($__pdb_lit_N), so queries that differ only in literal values share one plan (see The plan cache).per text
Semantic checksScope, aggregation and type rules run on the original text, so error messages keep your literals.every execution
WHERE foldingSimple predicates (n.p = $v, n:Label, comparisons with literals or parameters) move into the node patterns, where an index can use them.plan build
LoweringThe AST becomes an operator tree (Op). Comma patterns and consecutive MATCH clauses that share endpoints fold into one path. The remaining patterns form a left-deep join chain. OPTIONAL MATCH becomes LeftJoin. A non-MATCH clause between two MATCH clauses (UNWIND, CALL, WITH …) makes the later part an Apply seeded by the earlier rows.plan build
Access-path rewriteJoin ordering and the hash-or-nested-loop choice per join step, filter push-down, label rewrites, compound multi-key sorts, and the partial COUNT rewrite.plan build
Plan cacheThe result of the stages above is cached.per text
ExecutionBefore the operator tree runs, the executor tries the whole-query fast paths (label counts, filtered counts, group counts, edge-sorted top-k, spatial kNN). Each MatchScan then chooses its entry node and access path. Specialised operators (path counts, factorized aggregates, intersections, parallel morsels) are admitted by their own checks.every execution

Read this table as two halves. Everything up to the plan cache is decided once per query text and then reused. Everything in the execution row is decided again on every run, using the current statistics.

Figure 1. The pipeline. The plan cache stores the rewritten operator tree; the entry choice (with the A2 entry-decision cache, introduced in RFC 0042 A6), fast paths and operator admission are decided on every execution.

The plan cache

The plan cache holds the operator tree after the access-path rewrite. That tree fixes the join order and the per-step hash or nested-loop choice.

  • Scope. One cache per process holds up to 512 plans, keyed per database, and evicts the least recently used. There is no setting for its size. PATINADB_PLAN_CACHE=off (also 0, false or no) disables it, together with literal normalization.

  • Key. A plan is reused only for the same normalized text. User parameters ($personId) are kept by name, so every value of $personId shares one plan.

  • Literals. Literals are replaced by internal parameters. Normalization is skipped, and the plan is keyed by the exact text, when:

    • the query has two or more MATCH steps that reach join reordering;
    • the text contains comments, backticks or non-ASCII characters;
    • the normalized text does not parse.
  • Decided per execution, not cached.

    • the entry node and the access path of each MatchScan;
    • the order of a multi-pattern MatchScan;
    • all fast paths, limit rules and aggregate specialisations;
    • parallel admission and the auto modes.
  • Invalidation. Cached plans are dropped by:

    • any DDL, such as CREATE INDEX or CREATE EDGE SORTED INDEX;
    • clearing the graph (restore, snapshot install, reset);
    • DROP DATABASE.

    Under PATINADB_COST_MODEL=join, an accepted degree-catalog publication for the same database also makes the cached plan rebuild on its next lookup. Refused refreshes, ordinary data writes and publications in another database do not trigger this rebuild. Reference/entry plans retain their previous policy. Parameter values still share a plan; parameter-sensitive join planning remains a limitation between replans.

  • EXPLAIN and PROFILE plan afresh. They never read the plan cache. If the data or the statistics have changed since a query was first cached, EXPLAIN can therefore show a different join order from the one the cached plan executes. Under PATINADB_COST_MODEL=join, the decisions show a plan.cache … chosen=built line, which records the schema epoch the join decisions were made under.

The L3 result cache, which stores whole result tables, is separate; see Cache memory budget.

The plan.cache wording in the example below reflects this publication policy; the original example fixture and the other recorded decisions are unchanged.

Entry node and direction

Each MatchScan starts at one node of its path, the entry node, and expands from there in both directions. This choice is made again on every execution.

Figure 2. Entry choice. The margin depends on the provenance of the cheapest candidate’s inputs; the details follow in the two sections below.

Reference rules (fallback)

The reference selector decides wherever the entry cost model declines a pattern or keeps its pick by margin, and everywhere when PATINADB_COST_MODEL=off. It applies these rules in order:

  1. UUID shortcut. A node constrained by an id equality (id(n) = $id, elementId(n) = …) is the entry, without estimating anything else.
  2. Single node. A pattern with one node starts there.
  3. Sort hint. If ORDER BY names a property of a node in the pattern, that node is the entry. The exception is when its estimate is at least 10 times the cheapest node’s estimate (integer ratio, both estimates positive).
  4. Minimum cardinality. Otherwise the labelled node with the smallest estimate wins.
    • Ties go to the leftmost node.
    • The walk stops at the first estimate of 1 or less.
    • Unlabelled nodes are never estimated.
  5. Direction check. For chains of 3 to 9 nodes the planner can then switch to the other endpoint. This applies only when every node has exactly one label and no properties, and every relationship has a fixed length and one type. If the two endpoint estimates are within 2× of each other, it samples up to 32 anchors per hop and direction, with one shared 16,384-edge budget. There is no setting for this step.

The node estimates come from these rules (constants as in the source; they are heuristics, not measured optima):

Node patternEstimate
id(n) / elementId(n) equality1
no labelunknown; skipped while any labelled node exists (an all-unlabelled pattern starts at its first node, AllVertices)
label only (primary label)the label count
label only, label held only as a secondary labelthe total vertex count (a coarse upper bound)
n.p IN [...]the sum of the list lengths, capped at the label count
n.p = va bounded scan of up to 1,001 index keys; above that, present / NDV from the column statistics
n.p > v (and >=, <, <=)histogram interpolation; the label count when no statistics exist
STARTS WITH, CONTAINS, <>, otherthe label count ÷ 10

The first label is the primary label. Label counts, value indexes and compound indexes are keyed by it. A label that a node holds only as a secondary label (for example Post in CREATE (:Message:Post)) is not counted by the label index. The reference selector then falls back to the total vertex count. In the example dataset this is why (:Comment) is estimated at est rows=31070, the whole graph, instead of 20,000.

Once the entry node is chosen, the access path for it is the first rule that applies, in this priority order:

PriorityRuleUsed for
10entry.all_verticesunlabelled entry
12entry.in_seekn.p IN [...] on one label
14entry.uuid_pointid equality
16entry.keyset_multisortmulti-key keyset pagination
18entry.spatial_seekpoint-index predicates
20entry.compound_indexdeclared compound index
30entry.property_value_indexequality, range, STARTS WITH, or a sorted scan on one property
40entry.label_scaneverything else

Priority is fixed with one exception. When both a compound index and a single-property index apply, they are compared on an estimated rows + io cost. If the cost beats the priority order, the Physical: footer says so with an entry.cost_override: … line (see Cost-based selection).

The entry cost model (PATINADB_COST_MODEL=entry)

With PATINADB_COST_MODEL=entry (or join, the default, which includes it), the minimum cardinality rule (step 4) and the direction check (step 5) are replaced, for eligible patterns, by a whole-path cost in calibrated nanoseconds.

  • Eligible patterns. Fixed-length paths with at least one relationship, where every relationship has exactly one type. Patterns with an id constraint, variable-length relationships, shortestPath, untyped or type-alternative relationships, and patterns with a sort hint keep the reference rules. So do patterns whose estimates are not finite, and patterns whose planning scratch memory is refused.

  • What is priced. Every labelled node competes as the entry. Each candidate costs:

    • its entry access (seek or scan);
    • for every entry row, the expansion of both sides of the path, including label checks and property reads at each hop;
    • the construction of the cross product of both sides.

    The hop sizes come from the degree catalog, using the mean degree for the (relationship type, direction, anchor label, other label) group. Filters are assumed independent.

  • Regimes. The primitive costs were measured on one host (A2-cal v1, #1442). A hop is priced as “warm L2” only when the entry cache is enabled, its expected list fits the L2 admission cap (at most 8,192 edges), and the working set fits the level cap. Otherwise it is priced as “warm redb”. The cold regime is never chosen automatically.

  • Keyed consumers. Some operators repeat work per run of one binding. Today this is only the count over independent OPTIONAL one-hop branches. For those, the expected number of runs times the per-run cost is added as entry.cost.consumer.

  • Adoption margin. The model’s choice replaces the reference entry only when the predicted advantage clears a margin that depends on the inputs:

    • Exact inputs: no margin; the cheapest candidate is used.
    • Sampled degrees: the larger of 1.1× and the spread of the candidates’ estimated output rows.
    • Missing or stale degrees (Default, Stale): 2×.

    Ties keep the reference entry. These margins are unverified policy choices, not confidence intervals.

  • Missing statistics. If a degree group is missing or stale, the model uses a cached legacy fan-out or a default of 1. It also asks for one bounded background refresh, which runs on a single worker:

    • budget PATINADB_DEGREE_STATS_EDGE_BUDGET, default 1,000,000 directional edges; 0 disables the refresh;
    • a 120-second deadline;
    • a 5-second retry backoff per database.

    The query that triggered the refresh does not wait for it. The refresh= field of entry.cost reports what happened, for example started, not needed, single flight or cache budget refusal. A refusal means the entry cache is disabled, or the stats.degree region has less than the 256-group minimum left. A 2 GiB node fits the sample (#1442). So the default cost model does not need a manual CALL patinadb.stats.degrees(): the first queries of a shape plan with the fallback, later ones with the sample once it is published. The sample is node-local RAM, lost on restart, and costs a bounded edge sample plus one pass over all vertices on a background thread. Join orders chosen before it arrived stay in the plan cache until a schema change or eviction (see The plan cache); entry choices are re-priced when the catalog changes.

  • It needs the entry cache. The degree catalog lives in a region of the entry cache. A server enables the entry cache by default (PATINADB_CACHE_LIMIT=40%). Embedded use enables it only when PATINADB_CACHE_LIMIT is set. Without it, every degree lookup falls back to Default and the refresh is refused. This is the same IC2-shaped query run embedded without a cache limit:

  entry.cost _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"]: chosen=node[0] (est rows=1.931123270035404) (est ns=4155.373289255539) over node[2] (est rows=1.931123270035404) (est ns=844442.8018863855) over node[4] (est rows=60) (est ns=25337715.354519267); minimum calibrated primitive ns; dominant=entry access; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=cache budget refusal; independent filters; constants A2-cal v1 #1442; Default; repeats=3

All values and their side effects are listed in Entry and join cost model.

Joins

Which patterns become a join

Before any ordering, lowering folds patterns that share an endpoint into a single path. For example, (a)-[:R]->(b), (b)-[:S]->(c) becomes one path (a)-[:R]->(b)-[:S]->(c). Patterns stay separate when:

  • a pattern is OPTIONAL or binds a named path;
  • both patterns contain a relationship and come from different MATCH clauses;
  • the shared variable is an interior node of either path (branches and stars stay separate);
  • two patterns share both endpoints but use disjoint relationship types. They stay a two-key join instead of becoming one closed walk (LSQB Q2’s shape; see Closed paths and endpoint joins).

Separate MATCH clauses lower together like comma patterns. The exceptions are an OPTIONAL clause, a clause with its own WHERE or dynamic pattern properties, and a clause with two or more relationship patterns. Patterns that remain separate form a join chain.

Reference join planner (fallback)

The reference join planner orders joins when join costing declines or is switched off.

  1. Seed. The pattern with the smallest base estimate starts the chain; ties go to the first pattern. The base estimate is the smallest node estimate in the pattern. A pattern that touches a variable bound by an enclosing seed counts as 1.

  2. Order. Up to 8 patterns use a Selinger-style dynamic program that minimises the sum of intermediate result sizes. Above 8 patterns, a greedy order is used.

    • A connected join step is estimated as max(prefix, base), a foreign-key assumption. It does not use fan-out.
    • A disconnected step is estimated as prefix × base.
    • Connected patterns always come before disconnected ones. The planner never adds a cartesian product that the query did not already force.
    • Finally, patterns that are anchored on two or more bound nodes, or that close a cycle on a bound node, are moved forward.
  3. Strategy per step. A step becomes a seeded nested loop (Apply) when it is connected and either:

    • anchored as above; or
    • a point probe applies (one hop, or every interior node bound) and the nested-loop cost prefix × seek beats a scan of the right side.

    The right side must have at least 1,000 estimated rows, except when the whole step is evaluated per outer row. Every other step is a hash join (Join kind=hash); the right side is the build side.

  4. Cartesian products. A keyless join fails with a resource error once it would emit more than PATINADB_CARTESIAN_CAP rows (default 10,000).

  5. Semi-join pruning. In supported shapes, the hash table of a join is passed as a membership filter into a later traversal. That traversal then skips vertices that cannot join (up to 8 keys).

  6. Partial COUNT. An ungrouped count(*) over a keyed hash-join chain is rewritten into per-side grouped counts. These are joined on the keys and summed as products. In EXPLAIN this shows as a Join of two Aggregate group_by=… inputs, as in Example C.

Hash and cartesian joins emit combined rows on demand (#1468), preserving left scan order and right bucket/scan order, including duplicates. Under an active query memory context, new row backing and recursive scalar copies are admitted before allocation against the existing query/transaction budgets. A memory refusal terminates output with ResourceExhausted. The cartesian cardinality gate still checks the complete product before emitting any row. Probe key containers, recursive scalar copies and edge-formatting scratch also reserve before allocation (#1469). Their credit stays with the current probe and releases on advancing, exhaustion, error or early iterator drop, rather than accumulating in delivery. Missing bindings retain their skip semantics. Keyed build tables also admit their hash allocation, fresh composite keys and bucket storage (#1470), including old/new capacity overlap during growth. Shared membership filters retain the same table owner; the last owner releases its credit. Existing input row payloads are moved or shared, not re-accounted as new table payload. Join input collectors reserve slot capacity before growing (#1471), including old/new allocation overlap. Cartesian iterators keep both input permits until drop; keyed build releases its collector after handoff. Collection can fail its memory budget before the product cardinality is known. No new switch is introduced. Shared seed/import selection and APPLY/OPTIONAL merges now use the same fallible copier (#1473). Unique owned entries move; shared entries require an admitted recursive copy. CALL import visibility and first-binding precedence remain unchanged. Refusals propagate through reader, writer, predicate and COUNT/EXISTS callers. A governed APPLY iterator checks its original query memory context on every advance, including within the current seed. Copied payload credit stays with query delivery, including discarded temporary copies. Original row producers and other copy sites remain separate limitations, so this is not a complete join memory bound. The keyed emitter probes its immutable bucket per output row; its throughput impact has not been measured.

OPTIONAL MATCH never joins the chain. It becomes a LeftJoin that seeds each optional pattern from every left row. Under a query memory context, its input, clause extensions, results and memoized candidate row-slot buffers reserve capacity before growth/copy (#1472). Sort input and correlated/rooted MATCH candidate and accumulator containers use the same owner. Concurrently live containers count together; completed output keeps its credit through iterator/result lifetime. A refusal propagates through the existing resource-error path. Identity/SKIP filtering, clause-level WHERE and NULL extension, duplicate order and count/deadline guards are unchanged. The existing limits therefore may reject work previously admitted with uncounted buffers. Optional memo keys/table storage and recursive row/model producers remain separate limitations. No new knob or measured throughput claim.

Cost-based join ordering (PATINADB_COST_MODEL=join)

join adds degree-aware join costing on top of entry:

  • Search.

    • Up to 10 relations: a Pareto (cost, rows) subset dynamic program. It gives up and falls through when a frontier would need more than 8 states.
    • Up to 32 relations: an interval dynamic program over the greedy order.
    • Above 32: greedy.

    These limits are unverified policy. The search is left-deep and adds no cartesian products.

  • Pricing. For each step, a hash join (build + probe + row construction) is compared with a seeded nested loop (seeks plus expansion per outer row), in the same calibrated nanoseconds as the entry model. No new operator is introduced.

  • Guard. The reference plan is priced in the same model. The search result is adopted only if it is predicted to be faster by more than 1.1× (when both prices come from exact statistics) or 2× (otherwise). The reference plan is also kept when:

    • every pattern is edge-free;
    • a pattern closes a cycle, meaning a node variable repeats inside one pattern. A cycle spread over several comma patterns does not count;
    • an input cannot be modelled: shortest paths, variable-length or multi-type relationships;
    • the reference price is not finite, or planning scratch memory is refused;
    • the reference executes as a partial COUNT and the search result would not. The audit then says kept reference: it executes as a partial COUNT ….

On the LSQB, SF1 and Velr suites that were checked (#1442), the guard keeps the reference plan everywhere (RFC 0042, Increments A3). Treat join as an experiment that reports its reasoning, not as a tuning knob.

Statistics

StatisticSourceFreshnessUsed by
Label countkey-only count of the primary label indexcached; served until PATINADB_STATS_STALENESS_BUDGET (default 64) label generations oldentry estimates, join bases
Column statistics (present count, NDV, min, max, 16-bucket equi-depth histogram)one run-length pass over the primary label’s value indexsame budgetequality and range estimates
Legacy fan-out (mean edges per anchor)one pass over the edge table, capped at PATINADB_FANOUT_RECOMP_MAX_EDGES (default 100,000,000; partial above)per edge-type generationreverse-probe top-k; cache-only fallback for the cost model
Degree catalog (A1)explicit procedure or background samplerelative, see belowcost model, factorized and auto admission

Notes:

  • Generations. A generation advances once per dirty label or relationship type at each storage synchronisation, not once per written row.
  • Where statistics live. Statistics are kept in RAM only. The cache holds at most 4,096 entries or 4 MiB, is cleared completely when it overflows, and is lost on restart. Each cluster node computes its own statistics; they are never replicated.
  • Estimates vs guarantees. Every number here is an estimate. Statistics are never used to prove that a row is absent or to skip a check that results depend on.
  • CALL patinadb.stats(label?) shows exact, freshly computed values, including secondary-label holders. It is not a view of the planner’s cached (possibly stale, primary-label-only) entries, so its numbers can differ from the estimates in EXPLAIN.

The degree catalog

CALL patinadb.stats.degrees(edgeBudget?) builds the RFC 0042 A1 degree catalog and returns one row per (relationship type, direction, anchor label, other label) group. Both labels include secondary labels. Here is an excerpt from the example dataset:

edge_type | direction | anchor_label | other_label | edges | anchors | population | p50 | p99 | max | heavy_hitters | provenance | sample_edges | sample_anchors
  …
HAS_CREATOR | in | Person | Message | 30000 | 1000 | 1000 | 15 | 110 | 110 | [16 entries] | exact | 30000 | 1000
  …
KNOWS | in | Person | Person | 6500 | 1000 | 1000 | 7 | 9 | 9 | [16 entries] | exact | 6500 | 1000
KNOWS | out | Person | Person | 6500 | 1000 | 1000 | 7 | 7 | 155 | [16 entries] | exact | 6500 | 1000

How to read a row:

  • anchors counts anchors with at least one edge, while population includes zero-degree anchors.
  • p50 and p99 are upper bounds of log₂ buckets.
  • heavy_hitters lists up to 16 high-degree candidates with their exact observed degrees. The cost model uses an exact heavy-hitter degree only for a literal UUID seed; for anything else it uses the mean.
  • Exact vs sampled. Without an argument, the budget is PATINADB_FANOUT_RECOMP_MAX_EDGES. If both directions of the graph fit the budget, the pass is exact. Otherwise it samples 256 evenly spaced UUID ranges, assuming uniformly distributed UUIDs. sampled counts are then extrapolated, max is only the observed maximum, and groups that the sample missed are absent rather than proven empty.
  • Population. The population counts come from a full vertex scan, which the edge budget does not bound.
  • Failure. An exhausted budget, more than 4,096 groups, or a deadline fails the call and keeps the previous catalog.
  • Freshness. A group counts as stale for the entry and join models as soon as any of its three generations (relationship type, anchor label, other label) has moved. Factorized admission uses a looser rule: it refreshes when the generations have moved by more than max(64, population / 100).
  • Storage. The catalog is stored only when the entry cache is enabled (see above). Planner paths never block on building it; they request the background sample described in The entry cost model.

Specialised operators and switches

SwitchValuesDefaultPrerequisitesWhat it can changeDecision points
PATINADB_COST_MODELexactly off, entry, join (case-sensitive)joinentry cache for degree statisticsentry choice; join order (join); parallel admission; enables the auto modes belowentry.cost, entry.cost.hops, entry.cost.consumer, join.order, join.strategy, plan.cache, parallel.admission
PATINADB_FACTORIZEBoolean (0/false/off/no are off)ona query memory scopegrouped counts over two independent branches (B2); DISTINCT and existence, top-k within a factor, cross-branch counts (B3–B5)agg.factorized, factor.topk, factor.exists, factor.flatten
PATINADB_WCOJexactly off, on, auto (trimmed, case-insensitive)autoauto needs a cost model; intersections and cyclic joins need a query memory scopeclosing probes (C0), sorted intersections (C1); anti-intersection and cyclic joins (C2) only with onexpand.into, expand.intersect, entry.intersect, join.wcoj
PATINADB_CSRoff, on, auto, plus 1/true/yes for onautoauto needs a cost model; the entry cachededuplicating variable-length BFS (D1); with on also shortest paths and fixed hops (D2)csr.admission, traversal.source
PATINADB_QUERY_THREADSinteger, clamped to 1–321 (serial)idle pool workersmorsel-parallel scans, traversals, mergeable aggregates, top-k and Applyparallel.admission

All five are read once per process; restart after changing them. --check-config validates their values and prints the effective mode of the four RFC 0042 switches, marking defaults. Note that PATINADB_WCOJ rejects Boolean aliases: at runtime true and 1 mean off.

Defaults (since 2026-10-17, #1442). Unset or empty means the default in the table. An explicitly set value keeps the meaning it had before the defaults changed: off selects the reference path, and so does any value outside a switch’s grammar (--check-config rejects those). To run the reference planner and executor exactly as before, set all four to off:

PATINADB_COST_MODEL=off PATINADB_FACTORIZE=off PATINADB_WCOJ=off PATINADB_CSR=off

The default was chosen from the server comparison in Measured effects: Velr 0.544, LSQB 0.878, SF1 0.995 of all-off. PATINADB_QUERY_THREADS stays 1.

What auto means. Under auto, an operator engages only where it has its own cost admission, and only when PATINADB_COST_MODEL is entry or join. Without a cost model, auto behaves exactly like off. Operators without a cost admission (anti-intersection, the cyclic join, CSR shortest paths and CSR fixed hops) need an explicit on.

Figure 3. Switch map. “Uncosted” means the operator engages wherever its structural checks pass; “inert” means an auto mode without a cost model.

Prerequisites that decline silently. Factorized execution, sorted intersections and the cyclic join run only under a query memory scope:

  • On a server, Bolt reads get one from the memory governor.
  • Embedded code gets one from Dataset::query_table_read_with_memory.

Without a scope, PROFILE reports agg.factorized aggregate: chosen=flat over factorized; query memory scope required and the reference operator runs.

Factorized execution (PATINADB_FACTORIZE)

Factorized operators work on a fixed path that splits at an interior pivot into two independent branches. Instead of building every combination of the two branches, they read each branch once per pivot.

  • Grouped aggregates (B2).
    • Aggregates: count(*), count(x) and count(DISTINCT …), with group keys on the pivot or on the branch end nodes.
    • Exact-integer sum, min and max on a general path, but only with the cost model off.
    • Branches may share a relationship type only if they reach the pivot through it in the same way. Relationship uniqueness is then checked per pivot at run time.
  • B3–B5. DISTINCT over the pivot plus one branch, top-k within a factor, and cross-branch =/<> counts by inclusion–exclusion. A degenerate factor flattens back to the ordinary operator, before any output. The details are in Factor consumers.
  • Admission.
    • With the cost model off, every proven shape engages (forced; meant for tests and measurement).
    • With the cost model on, only the per-pivot count path is cost-admitted. It is admitted when 700 · F > 1.5 · (1000 + 300 · W + 30 · F) ns, where F is the estimated flat rows and W the walked elements per pivot, taken from the degree catalog. The weights were fitted on one host and are indicative (#1442). Missing or refresh-due degree evidence keeps the flat aggregate. SUM/MIN/MAX and join shapes are not costed and stay flat.
    • B3–B5 have no cost admission. They engage whenever the switch is on and their structural checks pass.

The same Velr-A1-shaped query (“pairs of authors whose comments reply to the same post”), first with PATINADB_FACTORIZE=on alone and then with the cost model and auto modes on as well:

  agg.factorized pivot=4 (per-pivot key multisets; runtime witness proof for shared relationship types; runtime admission)
  …
  agg.factorized aggregate: chosen=factorized over flat; selected; cached pivot count=Some(0); cached first-hop means=[Some(2.0), Some(2.0)]; PATINADB_COST_MODEL records its estimate separately; Cached; repeats=1
  agg.factorized pivot=4 (per-pivot key multisets; runtime witness proof for shared relationship types; runtime admission)
  …
  agg.factorized aggregate: chosen=flat (est rows=4) (est cost=2800) over factorized (est rows=4) (est cost=2320); cost model per pivot: flat rows=4.00, walk elements=4.00, flat=2800ns, factorized=2320ns, margin=1.5; Cached; repeats=1

Both runs return the same five rows. On this graph each post has two comments, so F = 4 flat rows per pivot against W = 4 walked elements. That fails the margin, and the cost model keeps the flat aggregate. The grouped count MATCH (p:Person)-[:KNOWS]->(q:Person)<-[:HAS_CREATOR]-(m:Message) RETURN q.lastName AS name, count(*) AS n ORDER BY name LIMIT 3 pivots at q. It has F = 195 against W = 36.5 and is admitted:

  agg.factorized pivot=2 (per-pivot key multisets; runtime admission)
  …
  agg.factorized aggregate: chosen=factorized (est rows=195) (est cost=17800) over flat (est rows=195) (est cost=136500); cost model per pivot: flat rows=195.00, walk elements=36.50, flat=136500ns, factorized=17800ns, margin=1.5; Cached; repeats=2

Worst-case optimal joins (PATINADB_WCOJ)

  • C0, closing probe (expand.into). When the last hop of a pattern returns to an already bound node, the executor can probe that one edge instead of scanning the whole adjacency and filtering. The closing relationship must be unnamed, single-typed and without properties. Under auto the probe is chosen when the cached list length or the catalog degree makes a full scan dearer than one seek; unknown degrees keep the scan.
  • C1, sorted intersection (expand.intersect). For c in N(a) ∩ N(b) shapes (triangles, mutual friends), the executor merges two UUID-sorted neighbour lists instead of expanding and filtering. Under auto, each candidate samples at most 256 keys per side. The intersection is used only when the binary plan’s intermediate result exceeds the output and the binary plan costs more than 1.5× the intersection. Sampling stops for a hop after 32 candidates whose expected gains do not pay for it, and resumes on every 64th. The anti-intersection for NOT (a)--(c) runs only with on.
  • C2, cyclic join (join.wcoj). Connected cyclic regions with at least three fixed, typed relationships (up to 16 vertex variables and 16 relationships) run as a leapfrog join over the adjacency. Named or property-filtered relationships join it as binary probes. OPTIONAL, variable-length paths, derived inputs and scans with ordering promises keep the reference execution. Runs only with on; there is no cost admission yet.
  • Triangle entry (entry.intersect). With auto and a cost model, a homogeneous undirected triangle may be entered at the node that gives C1 its complete prefix.

Limitations: Experimental cyclic joins require explicit activation.

CSR adjacency cache (PATINADB_CSR)

The CSR is a derived, read-only, in-memory copy of one relationship type’s adjacency in one direction. It is built in the background from the stored trees and is never written to disk or replicated.

  • When it is used. Until a CSR is built, and for any anchor written since the build, the existing reader is used. A CSR more than a quarter dirty is rebuilt.
  • Memory. Its share is one ninth of the decoded entry-cache levels’ allowance; the four existing levels keep two ninths each. With on, that share is reserved at start-up. With auto, it is reserved only once the CSR cache exists.
  • auto. Only deduplicating variable-length traversals (BFS), where count(DISTINCT …) or similar makes path multiplicity irrelevant, are cost-admitted. The expected expansions must save more than twice the dense-id lookups plus a 1/4096 share of the build scan; this policy is unverified. Shortest paths keep the reference reader.
  • on. Also enables shortest paths and single-type fixed hops (D2).

EXPLAIN neither builds nor reports a CSR. Only PROFILE shows traversal.source:

  csr.admission deduplicated BFS: chosen=csr candidate; degree/visits versus amortized build and lookup; uncertified caches still decline; Structural; repeats=1
  traversal.source "KNOWS" reverse=false: chosen=csr over l2/redb; clean anchor at compatible snapshot; Exact; repeats=225
  traversal.source "KNOWS" reverse=true: chosen=csr over l2/redb; clean anchor at compatible snapshot; Exact; repeats=225

In the same auto run, the C1 intersection’s sorted domains also consulted the CSR and requested its build (not built, then background build in progress in Example D). That run had no csr.admission decision for the triangle query. That excerpt was captured before the CSR auto containment: in the current source, auto admits only the deduplicating BFS, and intersection domains stay on the reference reader without requesting a build. They read the CSR only with PATINADB_CSR=on.

Parallel execution (PATINADB_QUERY_THREADS)

With a thread cap above 1, eligible read-only scans, fixed traversals, mergeable aggregates (not sum/avg), bounded top-k, sorted DISTINCT and seeded Apply can run as morsels on the query pool. Results and their order keep the serial contract. Admission depends on the cost model:

  • Cost model off. A scan or traversal goes parallel only above PATINADB_PAR_AGG_MIN_ROWS estimated rows (default 100,000, unverified).
  • Cost model on (A4). A plan goes parallel only when its estimated serial work exceeds 4 × (400 µs dispatch + 265 ns per morsel + 1.4 ns per row), and the parallel version is also cheaper. Some cases need more:
    • aggregates grouped by an entity add about 1 µs of serial work per group;
    • row-producing pipes still also need the row floor;
    • a serial specialisation (path count, covering input) keeps priority unless the parallel plan is four times cheaper.
  • Always.
    • Workers must be idle (parallel.load in the access profile).
    • Deferred-prefix pruning always keeps priority.
    • Edge morsels split a first hop only when its actual average fan-out reaches PATINADB_PAR_FANOUT_MIN_EDGES (default 50,000).

A two-hop grouped count (MATCH (a:Person)-[:KNOWS]-(b:Person)-[:KNOWS]-(c:Person) RETURN c.lastName AS name, count(*) AS n) with PATINADB_QUERY_THREADS=4. With the cost model off, the row floor compares the entry estimate (1,000) with 100,000 and stays serial:

      [2] MatchScan required=[(a:Person) -(b:Person) -(c:Person)] rows=377064 est=1000 q-error=377.064
  …
  parallel.admission traversal rows: chosen=serial over parallel (est rows=1000); max label estimate >= floor 100000; entry=1000; Cached; repeats=2

With PATINADB_COST_MODEL=join, the calibrated work estimate is compared with the overhead and the pool admits four threads:

      [2] MatchScan required=[(a:Person) -(b:Person) -(c:Person)] rows=377064 est=169000 q-error=2.231
  …
  parallel.admission calibrated work: chosen=parallel (est rows=169000) (est cost=2830130.3734979997) over serial (est rows=169000) (est cost=8780502.616999999); serial_ns > 4 * overhead_ns=635004.719248; effective_threads=4; morsels=4; A2-cal v1, unverified margin; Cached; repeats=2
  …
  parallel.admission pool load: chosen=parallel over serial; idle=8/8; requested cap=4; admitted cap=4; Exact; repeats=1
  …
  parallel.load: admitted, idle 8/8, threads 4

More detail is in Day-2 operations and the PATINADB_QUERY_* rows of Configuration.

Fast paths

These run automatically, without a switch:

  • Limit rules. Rules for ORDER BY … LIMIT are tried in this priority order, and the walk is recorded as limit.rule:
    1. edge_sorted_topk (needs an edge-sorted index);
    2. spatial_knn;
    3. reverse_probe, which is cost-compared against the fan-out top-k;
    4. bounded_topk, an O(k) heap;
    5. window.
  • Path counts. agg.disjoint_path_count and agg.grouped_disjoint_path_count count fixed paths that split into branches with disjoint relationship types, without building cross-product rows.
  • Optional counts. agg.independent_optional_count handles count(*) over up to 8 independent optional one-hop branches.
  • Covering input. agg.covering (PATINADB_READ_COVERING, default on) applies to broad integer aggregates of at least 4,096 estimated rows.
  • Whole-query counts. Label, filtered and grouped count scans.

The full list is in Fast paths that make queries cheap.

Reading Decisions: in EXPLAIN and PROFILE

EXPLAIN returns a plan string. In order, it contains:

  • the operator tree, read bottom-up;
  • Storage: and property-layout lines;
  • an optional Physical: footer with planned rules and cost lines;
  • Decisions:;
  • optional Advice: and Warning [...] lines.

PROFILE runs the query and also returns elapsed_ms. Its plan string adds:

  • Planned access (before execution): and the same planned tree;
  • Executed operators (query thread): with actual row counts;
  • Decisions (executed):, the decisions taken during this execution;
  • the executed aggregate access, the resolved property storage and the storage request counts.

Each decision line has this form:

  <point> <subject>: chosen=<alternative> [(est rows=…)] [(est ns|cost=…)] over <alternative> …; <reason>; <provenance>; repeats=<n>
  • point: the decision family. Examples are entry.node, entry.access, entry.cost, join.order, limit.rule, agg.factorized, parallel.admission and traversal.source.
  • subject: what was decided. This is usually the pattern, with labels and directions, or a step such as step 1.
  • chosen / over: the chosen alternative first, then the rejected ones. Priority walks stop at their winner, so later alternatives may be missing.
  • estimates: est ns is used for entry.cost and for decisions whose reason starts with calibrated ns; other estimates are in the selector’s own units (est cost). They are estimates, not timings.
  • reason: the rule or the margin that decided.
  • provenance: where the inputs came from.
    • Exact: a fresh, exact statistic.
    • Sampled { size }: sampled evidence.
    • Cached: a value reused from a cache or catalog.
    • Histogram: column histogram interpolation.
    • Stale: an outdated statistic.
    • Default: no statistic; a fallback value was used.
    • Structural: a rule with no numeric estimate.
  • repeats: how often the same point, subject and choice recurred.
    • Seeded and nested executions repeat decisions.
    • The estimates shown are from the first occurrence.
    • At most 128 distinct decisions are kept.

In Executed operators, [n] is the preorder index of the executed plan, and rows= counts rows actually pulled from that operator. A LIMIT can leave its input only partly consumed. rows=unobserved (bypassed/fused) marks an operator that a fast path replaced or fused. A MatchScan (and, under PATINADB_COST_MODEL=join, a join) also shows its estimate and its q-error: the larger of estimate/actual and actual/estimate, with zero counted as one. What the estimate means depends on the mode:

  • Cost model off. The MatchScan estimate is the entry node’s estimate, not the path’s output. A multi-hop scan therefore shows a large q-error even when the plan is right.
  • Cost model on. The estimate is the chosen entry’s predicted output rows from entry.cost, which is directly comparable to rows=.

A large q-error shows where an estimate was wrong. It does not mean the plan was wrong. Worker-internal rows are not shown.

The examples below are real output from an in-process release build of this source on the generated example dataset (1,000 persons, 30,000 messages; #1442). Lines marked … are omitted. The query texts are the SF1 Interactive and LSQB texts from crates/patinadb/src/plan/exec/ldbc_pins/ (IC2, IC9) and ldbc_pins/parallel_admission.rs (LSQB). The row counts are logical work on a toy graph and are no performance measurement.

Example A: an IC2-style friends’ messages page

MATCH (:Person {id: $personId})-[:KNOWS]-(friend:Person)<-[:HAS_CREATOR]-(message:Message)
WHERE message.creationDate <= $maxDate
RETURN friend.id AS personId, friend.firstName AS personFirstName, friend.lastName AS personLastName,
       message.id AS postOrCommentId, coalesce(message.content, message.imageFile) AS postOrCommentContent,
       message.creationDate AS postOrCommentCreationDate
ORDER BY postOrCommentCreationDate DESC, toInteger(postOrCommentId) ASC
LIMIT 20

EXPLAIN with every switch off (personId = 1):

Limit skip=0 count=20
  Sort keys=2
    Project items=6 distinct=false return_star=false
      MatchScan required=[[entry](_:Person) -(friend:Person) -(message:Message) → PropertyIndex[id] = est.1]
Storage: native (allocated database fits detected RAM at open)
Vertex property storage: owner-key directory; canonical values <= 256 serialized bytes inline, wider values in property/owner payload table; automatic physical routing, existing query access operators retained
Physical:
  limit.bounded_topk
  parallel.topk: serial: query thread cap is one
  parallel.frontier: serial: query thread cap is one
Decisions:
  entry.node _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"]: chosen=node[0] (est rows=1); minimum cardinality; stop at <=1; unlabelled nodes skipped; Default; repeats=3
  entry.direction admission: chosen=fallback; shape, cardinality separation or sampling unavailable; retain pivot; Structural; repeats=3
  entry.access None: chosen=property_value_index; static priority; no complete cost comparison; Structural; repeats=3
  limit.rule edge_sorted_topk: chosen=declined; priority walk; stop at first applicable rule; Structural; repeats=2
  limit.rule spatial_knn: chosen=declined; priority walk; stop at first applicable rule; Structural; repeats=2
  limit.rule reverse_probe: chosen=declined; priority walk; stop at first applicable rule; Structural; repeats=2
  limit.rule bounded_topk: chosen=applicable; priority walk; stop at first applicable rule; Structural; repeats=2
  • entry.node … chosen=node[0] (est rows=1): the id index equality gives an estimate of 1. The walk stops there, so the other nodes are never estimated.
  • entry.access … property_value_index: the entry is a point lookup on the value index (PropertyIndex[id] = est.1 in the tree).
  • limit.rule: no edge-sorted index covers this sort, so the walk ends at bounded_topk, a 20-row heap instead of a full sort.

With PATINADB_COST_MODEL=join and the degree catalog loaded, the same EXPLAIN prices all three entries:

Decisions:
  entry.cost.hops _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"] node[0]: chosen=estimate (est rows=376.5690376569038) (est cost=38508.96192405824); right[0] scanned=13.000 output=13.000 check_ns=8.933533 record_probes=0 regime=warm L2; right[1] scanned=390.000 output=376.569 check_ns=8.933533 record_probes=0 regime=warm L2; ; Exact; repeats=3
  entry.cost.hops _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"] node[2]: chosen=estimate (est rows=376.56903765690384) (est cost=3045511.749845188); left[0] scanned=13000.000 output=13.000 check_ns=8.933533 record_probes=0 regime=warm L2; right[0] scanned=30000.000 output=28966.849 check_ns=8.933533 record_probes=0 regime=warm L2; ; Exact; repeats=3
  entry.cost.hops _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"] node[4]: chosen=estimate (est rows=376.5690376569038) (est cost=40711683.6658395); left[0] scanned=28966.849 output=28966.849 check_ns=8.933533 record_probes=0 regime=warm L2; left[1] scanned=376569.038 output=376.569 check_ns=8.933533 record_probes=0 regime=warm L2; ; Exact; repeats=3
  entry.cost _:["Person"] - ["KNOWS"]:Undirected - friend:["Person"] - ["HAS_CREATOR"]:Inbound - message:["Message"]: chosen=node[0] (est rows=376.5690376569038) (est ns=38508.96192405824) over node[2] (est rows=376.56903765690384) (est ns=3045511.749845188) over node[4] (est rows=376.5690376569038) (est ns=40711683.6658395); minimum calibrated primitive ns; dominant=expansion; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=not needed; independent filters; constants A2-cal v1 #1442; Exact; repeats=3
  entry.access None: chosen=property_value_index; static priority; no complete cost comparison; Structural; repeats=3
  • entry.cost.hops shows, for each candidate, the scanned and accepted rows per hop, the label-check cost and the regime (warm L2). For node[0], 13 KNOWS neighbours are expected (the mean undirected degree), then 390 messages, 376.6 of which pass the date filter.
  • The cost model picks the same entry as the reference. Every input is Exact, so no margin applies.

PROFILE then shows how far the estimate was off:

Executed operators (query thread):
  [0] Limit skip=0 count=20 rows=20
    [1] Sort keys=2 rows=unobserved (bypassed/fused)
      [2] Project items=6 distinct=false return_star=false rows=unobserved (bypassed/fused)
        [3] MatchScan required=[(:Person) -(friend:Person) -(message:Message)] rows=5300 est=376.5690376569038 q-error=14.074

Person 1 is one of ten hubs in the dataset, with 155 outgoing KNOWS edges. The catalog’s mean degree cannot see that, hence the q-error of 14. Only a literal UUID seed would use the exact heavy-hitter degree. The plan is still the best one available: a point entry followed by expansion.

Example B: IC9 and an edge-sorted index

IC9 collects friends and friends of friends, then pages their messages by date. Without a covering index, PROFILE with every switch off shows the seeded Apply expanding HAS_CREATOR once per friend:

Executed operators (query thread):
  [0] Retain keep=[personId,personFirstName,personLastName,commentOrPostId,commentOrPostContent,commentOrPostCreationDate] rows=20
    [1] Limit skip=0 count=20 rows=20
      [2] Sort keys=2 rows=unobserved (bypassed/fused)
        [3] Project items=7 distinct=false return_star=false rows=unobserved (bypassed/fused)
          [4] Apply imports=[] scope=InheritOuter rows=17550
            [5] Retain keep=[friend,message] rows=565
              [6] Unwind binding=friend rows=unobserved (bypassed/fused)
                [7] Aggregate group_by=0 aggs=1 rows=1
                  [8] Filter rows=2344
                    [9] MatchScan required=[(root:Person) -(friend:Person)] rows=2344 est=1 q-error=2344.000
            [10] MatchScan required=[(friend:_) -(message:Message)] rows=unobserved (bypassed/fused)
  …
  traversal.source fixed ["HAS_CREATOR"] reverse=true: chosen=redb over csr over l2; CSR off or auto (fixed hops require on); Exact; repeats=565
  …
Resolved vertex property storage (query thread; logical resolutions, not physical I/O; workers/caches/buffers may bypass):
  inline=42100 canonical bytes=241541; external=0 canonical bytes=0

After CREATE EDGE SORTED INDEX FOR (m:Message)<-[:HAS_CREATOR]-() ON m.creationDate (run on a copy of the dataset), the limit.edge_sorted_topk rule applies. The ordered frontier then merges the friends’ date-sorted edge lists. The HAS_CREATOR traversal reader is entered 20 times instead of 565 (see repeats):

Physical:
  limit.edge_sorted_topk
  …
Executed operators (query thread):
  [0] Retain keep=[personId,personFirstName,personLastName,commentOrPostId,commentOrPostContent,commentOrPostCreationDate] rows=20
    [1] Limit skip=0 count=20 rows=20
      [2] Sort keys=2 rows=unobserved (bypassed/fused)
        [3] Project items=7 distinct=false return_star=false rows=unobserved (bypassed/fused)
          [4] Apply imports=[] scope=InheritOuter rows=unobserved (bypassed/fused)
            [5] Retain keep=[friend,message] rows=565
              [6] Unwind binding=friend rows=unobserved (bypassed/fused)
                [7] Aggregate group_by=0 aggs=1 rows=1
                  [8] Filter rows=2344
                    [9] MatchScan required=[(root:Person) -(friend:Person)] rows=2344 est=1 q-error=2344.000
            [10] MatchScan required=[(friend:_) -(message:Message)] rows=unobserved (bypassed/fused)
  …
  traversal.source fixed ["HAS_CREATOR"] reverse=false: chosen=redb over csr over l2; CSR off or auto (fixed hops require on); Exact; repeats=20
  …
Resolved vertex property storage (query thread; logical resolutions, not physical I/O; workers/caches/buffers may bypass):
  inline=92 canonical bytes=506; external=0 canonical bytes=0

The Apply is now fused (unobserved), and 92 property resolutions replace 42,100. These are logical counts from one run each, not timings. The q-error of 2,344 on [9] compares the entry estimate (1, the id lookup) with the 2,344 rows that the KNOWS*1..2 expansion produced. With the cost model off, that is expected. The cost model would not change it either, because it does not price variable-length paths.

Example C: an LSQB-style count

LSQB Q2 with PATINADB_COST_MODEL=join:

MATCH (person1:Person)-[:KNOWS]-(person2:Person),
  (person1)<-[:HAS_CREATOR]-(comment:Comment)-[:REPLY_OF]->(post:Post)-[:HAS_CREATOR]->(person2)
RETURN count(*) AS count
Planned access (before execution):
Project items=1 distinct=false return_star=false
  Aggregate group_by=0 aggs=1
    Join kind=hash keys=[person1,person2]
      left:
        Aggregate group_by=2 aggs=1
          MatchScan required=[[entry](person1:Person) -(person2:Person) → LabelScan ⚠ (no matching index) est.1000]
      right:
        Aggregate group_by=2 aggs=1
          MatchScan required=[(person1:Person) -(comment:Comment) -(post:Post) -[entry](person2:Person) → LabelScan ⚠ (no matching index) est.1000]
  …
Executed operators (query thread):
  [0] Project items=1 distinct=false return_star=false rows=1
    [1] Aggregate group_by=0 aggs=1 rows=1
      [2] Join kind=hash keys=[person1,person2] rows=269
        [3] Aggregate group_by=2 aggs=1 rows=13000
          [4] MatchScan required=[[entry](person1:Person) -(person2:Person) → LabelScan ⚠ (no matching index) est.1000] rows=13000 est=13000 q-error=1.000
        [5] Aggregate group_by=2 aggs=1 rows=20000
          [6] MatchScan required=[(person1:Person) -(comment:Comment) -(post:Post) -[entry](person2:Person) → LabelScan ⚠ (no matching index) est.1000] rows=20000 est=20000 q-error=1.000
Decisions (executed):
  join.strategy step 1: chosen=Hash (est rows=31070) over NestedLoop (est rows=1000); connected and anchored/probe eligible; otherwise hash; Structural; repeats=1
  join.order bases=[1000.0, 31070.0]: chosen=[0, 1] (est rows=31070) (est cost=32070); DP (cap 8), anchored priority; Default; repeats=1
  …
  join.order person1:["Person"] - ["KNOWS"]:Undirected - person2:["Person"] | person1:[] - ["HAS_CREATOR"]:Inbound - comment:["Comment"] - ["REPLY_OF"]:Outbound - post:["Post"] - ["HAS_CREATOR"]:Outbound - person2:[]; relations=2; guard: chosen=reference [0, 1] (est rows=260) (est ns=34999058.497517996) over subset DP [1, 0] (est rows=403.90999999999997) (est ns=33889480.401324295); calibrated ns; kept reference: predicted advantage below 1.1x; Exact; repeats=1
  plan.cache join planning epoch: chosen=built; schema_epoch=0; retained decisions; data writes retain plans; new degree catalogs replan; Cached; repeats=1

How to read it:

  • The join. The two patterns share both endpoints but use disjoint relationship types, so they stay separate join inputs.
  • The rewrite. The partial COUNT rewrite turned the hash join into a join of two grouped counts: 13,000 and 20,000 grouped rows meet in 269 key matches.
  • The reference decisions. join.order (DP over 2 patterns) and join.strategy (hash) were made by the reference planner. bases=[1000.0, 31070.0] shows the secondary-label fallback for the second pattern.
  • The guard. The join.order … guard line is the A3 search. It found a different order 3% cheaper, which is below the 1.1× margin for exact inputs, so the reference order was kept.
  • The cache entry. plan.cache … chosen=built records the schema epoch of these decisions.
  • Estimate quality. Both MatchScan estimates have a q-error of 1.000. Under the cost model they are the predicted output rows from the degree catalog. With the switches off, the same scans show the label-count entry estimate of 1,000.

A keyed consumer looks like this. These lines are from LSQB Q7 under PATINADB_COST_MODEL=entry:

  entry.cost.consumer _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"] node[0]: chosen=keyed runs (est cost=18684342.197546996); message:LIKES runs=30000.000 ns_per_run=305.783; message:REPLY_OF runs=30000.000 ns_per_run=317.028; ; Exact; repeats=1
  …
  entry.cost.consumer _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"] node[2]: chosen=keyed runs (est cost=18684342.197546996); message:LIKES runs=30000.000 ns_per_run=305.783; message:REPLY_OF runs=30000.000 ns_per_run=317.028; ; Exact; repeats=1
  …
  entry.cost.consumer _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"] node[4]: chosen=keyed runs (est cost=18684342.197546996); message:LIKES runs=30000.000 ns_per_run=305.783; message:REPLY_OF runs=30000.000 ns_per_run=317.028; ; Exact; repeats=1
  …
  entry.cost _:["Tag"] - ["HAS_TAG"]:Inbound - message:["Message"] - ["HAS_CREATOR"]:Outbound - creator:["Person"]: chosen=node[0] (est rows=30000) (est ns=29063112.205906995) over node[2] (est rows=30000) (est ns=38630507.417546995) over node[4] (est rows=30000) (est ns=29369677.606546998); minimum calibrated primitive ns; dominant=keyed consumer; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=not needed; independent filters; keyed-consumer runs priced; constants A2-cal v1 #1442; Exact; repeats=1

Example D: a triangle count under WCOJ

MATCH (a:Person)-[:KNOWS]-(b:Person)-[:KNOWS]-(c:Person)-[:KNOWS]-(a)
RETURN count(*) AS triangles

With COST_MODEL=join WCOJ=auto CSR=auto FACTORIZE=on, under a query memory scope:

Executed operators (query thread):
  [0] Project items=1 distinct=false return_star=false rows=1
    [1] Aggregate group_by=0 aggs=1 rows=1
      [2] MatchScan required=[(a:Person) -(b:Person) -(c:Person) -(a:_)] rows=26760 est=338000 q-error=12.631
  …
  entry.cost a:["Person"] - ["KNOWS"]:Undirected - b:["Person"] - ["KNOWS"]:Undirected - c:["Person"] - ["KNOWS"]:Undirected - a:[]: chosen=node[2] (est rows=338000) (est ns=22099296.471) over node[0] (est rows=338000) (est ns=82355575.995) over node[4] (est rows=338000) (est ns=23492927.619); minimum calibrated primitive ns; dominant=row construction; warm redb default, L2 only within list and working-set caps; A1 population mean or legacy-default cache-only fallback; refresh=not needed; independent filters; constants A2-cal v1 #1442; Default; repeats=1
  entry.intersect homogeneous triangle: chosen=node[0]; calibrated closing-domain candidate beats binary expansion; complete prefix required; Structural; repeats=1
  …
  expand.intersect bounded closing domains: chosen=intersect (est rows=13) (est cost=3120.355953) over binary (est rows=182) (est cost=6236.583665); left=Some(Exact(13.0)); right=Some(Exact(14.0)); closing=14 (homogeneous proxy); lower bounds must win over their open tails; Sampled { size: 256 }; repeats=11410
  traversal.source "KNOWS" reverse=false: chosen=l2/redb over csr; not built; Exact; repeats=22820
  traversal.source "KNOWS" reverse=true: chosen=l2/redb over csr; background build in progress; Exact; repeats=22820
  expand.intersect two-hop closing domain: chosen=intersect over binary; auto cost admitted; sorted domains; Structural; repeats=11410
  expand.into closing degree: chosen=scan; cached domain length or catalog degree versus one seek; unknown retains scan; Structural; repeats=36430
  …
  expand.into closing degree: chosen=probe; cached domain length or catalog degree versus one seek; unknown retains scan; Structural; repeats=6644
  expand.into repeated binding: chosen=probe over scan; auto cost admitted; fixed bound target; Structural; repeats=6644
  expand.intersect bounded closing domains: chosen=binary (est rows=2080) (est cost=7658.041013999999) over intersect (est rows=13) (est cost=10437.203915000002); left=Some(Exact(13.0)); right=Some(Exact(160.0)); closing=160 (homogeneous proxy); lower bounds must win over their open tails; Sampled { size: 256 }; repeats=1590
  • entry.cost would start at b (node[2]). entry.intersect then overrides this to a, which gives the intersection its complete prefix.
  • expand.intersect bounded closing domains is the per-candidate sampling decision. With 13 and 14 neighbours the intersection wins. Against a hub with 160 neighbours, the binary plan wins. Both appear, with their own repeats.
  • expand.into … probe is the C0 closing probe, admitted where the degree makes a scan dearer than one seek.

With WCOJ=on the cyclic join takes the whole pattern, and the MatchScan becomes unobserved:

Executed operators (query thread):
  [0] Project items=1 distinct=false return_star=false rows=1
    [1] Aggregate group_by=0 aggs=1 rows=1
      [2] MatchScan required=[(a:Person) -(b:Person) -(c:Person) -(a:_)] rows=unobserved (bypassed/fused)
  …
  join.wcoj variable order / (domain, conditional) estimates: [(Some("a"), Some(1000.0), Some(1000.0)), (Some("b"), Some(1000.0), Some(13.0)), (Some("c"), Some(1000.0), Some(0.16899999999999998))]: chosen=leapfrog over binary; completed base adjacency join; Cached; repeats=1

All three runs (off, auto, on) returned 26,760 triangles.

Practical guidance

Make the planner’s job easy

  • Use parameters. Parameters share one cached plan and keep plan-build cost off the hot path. Literals are normalized too, but not in queries with two or more MATCH clauses or with comments. Remember that every value of a parameter shares the join order built for the first one.
  • Label the anchor and its neighbours. Unlabelled nodes are skipped as entry candidates. Put the most selective label first when you create nodes, because only the primary label has exact counts and value indexes. A label used only as a secondary label is estimated as the whole graph.
  • Filter on indexed properties of labelled nodes.
    • Equality, range and STARTS WITH on a property of a node’s primary label can seek the label-scoped value index (see the data model); the IC9 plan of Example B shows (message:Message) → PropertyIndex[creationDate] < est.30000 although the dataset declares no index on creationDate.
    • Declare a compound index for equality plus range or sort combinations.
    • Declare an edge-sorted index for “traversal, then ORDER BY target.prop LIMIT k”. EXPLAIN prints an Advice: line with the exact DDL when such an index would apply.
  • Paginate with keys, not deep SKIP. WHERE n.p > $cursor ORDER BY n.p LIMIT k seeks to the cursor. SKIP n still has to walk past n qualifying rows. See Planning note for filtered pages.
  • Keep comma patterns connected. A pattern that shares no variable becomes a cartesian product, capped at 10,000 rows by default.
  • Avoid literal-only differences in big multi-MATCH queries. Such queries are cached per exact text, so each literal variant builds its own plan.

Diagnosing a slow plan, step by step

  1. EXPLAIN first. Check the [entry] marker and the access path of every MatchScan. Warning signs:
    • LabelScan ⚠ (no matching index), or AllVertices ⚠;
    • estimates of est.∞ or of the whole graph (secondary-label fallback);
    • Join without keys (a cartesian product).
  2. Read Decisions:. Find the entry.node or entry.cost line for the slow pattern. Default or Stale provenance means the planner had no usable statistics. Check the limit.rule walk and any Advice: line.
  3. PROFILE.
    • Compare rows= with est= for every MatchScan and join; the largest q-error is usually the culprit.
    • Check whether expensive operators are unobserved (a fast path engaged), or carry large repeats (seeded per outer row).
    • The traversal.source lines show whether adjacency came from l2, redb or csr.
  4. Fix the input before the knob. Add the label, the index or the parameter. Rewrite the query so the selective pattern is connected to the rest.
  5. Then try a switch on a test system, comparing PROFILE output and timing with and without it. The RFC 0042 switches are on by default, so the comparison is usually against off.
SymptomWhat to try
Entry on a large label although a small one exists, q-error large at the entryCALL patinadb.stats.degrees() for an exact catalog (the default join model otherwise waits for its background sample); compare with PATINADB_COST_MODEL=off
Comma-pattern join order looks wrongread the join.order … guard line; compare with PATINADB_COST_MODEL=entry or off
Counts or grouped counts over two branches of a pivot build huge row countsPATINADB_FACTORIZE is on by default; with the cost model only the count path is costed, so PATINADB_COST_MODEL=off engages every proven shape
Triangles, mutual friends, cycles over one relationship typePATINADB_WCOJ=auto is the default; on for the cyclic join
Deep variable-length reachability (count(DISTINCT f) over *1..k)PATINADB_CSR=auto is the default; check csr.admission
A query is slower than before the 2026-10-17 default changecompare with each switch off alone, then all four off (the previous behaviour)
Large scans or aggregations on an idle multi-core hostPATINADB_QUERY_THREADS > 1, preferably with a cost model
Small point-anchored queries are slower on their first execution with a cost modelExpected; repeat executions reuse the cached entry choice (see Planning budget)

Known limitations

  • The reference join estimate ignores fan-out, and cached join orders are never re-validated (see The plan cache).
  • The cost model assumes independent filters and uses mean degrees. Correlated paths and hubs reached through non-literal seeds can be misestimated, as in Example A.
  • Statistics are primary-label only, except for the degree catalog.
  • The RFC 0042 switches are on by default since 2026-10-17; the measured gain is suite-level, and single queries can be slower (see Measured effects). See Limitations, in particular the sections on cyclic joins and factorized operators.

Measured effects

These are the joint switch-matrix results recorded in RFC 0042 (docs/rfcs/0042-query-engine-modernisation.md, section “Joint switch matrix”, #1442). The ratios are geometric means of warm medians, relative to every switch off; below 1 is faster. They come from a shared host with documented disturbances. Treat them as indicative. They are not a qualification and not a promise for your workload.

Suiteallauto / off (before fixes)allauto / off (fixed head, 3 rounds)
Velr (11 + OLTP)0.5330.592
LSQB SF0.1 (9)1.0720.865
LDBC SF1 Interactive (28)1.1291.011

allauto is COST_MODEL=join FACTORIZE=on WCOJ=auto CSR=auto. In the same runs:

  • FACTORIZE carried most of the Velr gain (A1 0.09).
  • WCOJ gave LSQB Q3 0.80–0.81.
  • SF1 IC2 stayed 8–18% slower with the cost model, with an identical plan. The targeted IC2 line-2 timing was 9.86 ms off against 12.04 ms allauto: per-query cost-model work, not a plan change. The A2 entry-decision cache below removed it. With it, allauto against off gave Velr 0.544, LSQB 0.878 and SF1 0.995, IC2 0.82/0.81 and IC14 0.80/0.71 (3 rounds; RFC 0042 A6). IC10, IC11 line 3 and IC12 line 3 stayed 3–10% slower (IC11 line 3 at 1.10).
  • The maintainer adopted allauto as the default on 2026-10-17 on this result (#1442).

Per case, the A6 comparison that the default was adopted on looks like this. Darker bars changed by at least 3% in the same direction in every round; lighter bars did not.

Figure 4a. Velr per case. A1, A4, A6 and A8 are the cases that RFC 0042 attributes to FACTORIZE (indicative, #1442).

Figure 4b. LSQB per case: Q3, Q4, Q5 and Q8 are faster; no case is consistently slower (indicative, #1442).

Figure 4c. SF1 per case: IC2 and IC14 are faster; IC8 line 3, IC10, IC11 and IC12 line 3 are consistently 3–10% slower (indicative, #1442).

Figure 4d. Leave-one-out on the earlier, pre-fix binary: removing FACTORIZE costs Velr most. The three combined-switch regressions that RFC 0042 records for this run (LSQB Q4 and Q7, SF1 IC8) were fixed afterwards; compare the table above (indicative, #1442).

Planning budget

With a cost model on, the entry model runs every time a pattern is executed, because the entry choice is not part of the cached plan. Three mechanisms keep that cheap without changing any decision (RFC 0042 A6, #1442):

  • Catalog reuse. The degree catalog is decoded once per transaction and published catalog, and each label and relationship-type generation is read once.
  • Exact pruning. Candidates are priced in ascending order of a lower bound: rows × non-empty sides × one seek. A candidate whose bound already exceeds the best estimate is skipped. EXPLAIN and PROFILE still price and show every candidate.
  • A2 entry-decision cache (introduced in RFC 0042 A6). A completed A2 choice is reused by later executions of the same pattern with the same parameter values.
    • The cache holds up to 64 entries per process, within the governor-visible planner.entries cap of min(Q/16, 4 MiB) estimated bytes (#1467). Q is the auxiliary cache quantum after native/CSR adjustments. PATINADB_CACHE_LIMIT=0 disables reuse; there is no separate A2 switch.
    • On a fill, pattern and parameter ownership is estimated and reserved before copying. Oversize keys, values exceeding the 128-level sizing traversal, and function/expression AST literals without a layout model skip this optional cache. The query still uses the ordinary entry planner. Hits retain exact key comparison and snapshot validation without repeating the sizing walk. The byte model includes live construction and evicted-but-pinned readers; it is not an exact allocator or RSS bound.
    • Cache contention causes a miss or skipped fill, so planning uses the ordinary cost model. Snapshot metadata is validated outside the global cache mutex (#1466); every existing dependency check still applies.
    • An entry is reused only while the schema, the statistics, the published degree catalog and every label/relationship-type generation the choice read are unchanged.
    • Transactions that wrote, snapshot views, EXPLAIN/PROFILE and runs that requested a statistics refresh do not use it.
    • A valid hit skips entry costing; validating its metadata still performs reads (RFC 0042 A6). Native READY and generation readers now decode borrowed bytes (#1474), avoiding their owned value copy while keeping all these reads and dependency checks. Overlay reads retain their existing fallback, and buffered own writes can still copy during lookup. No new contention or query-throughput measurement is claimed.

A budget gate that skipped the cost model for cheap-looking queries was measured and not adopted. It judged each inner pattern alone, so a query with an expensive pattern comprehension lost the model’s better entry. See RFC 0042 A6.

Appendix: the example dataset

The outputs above were produced on a graph generated by these statements, executed in order on an empty embedded database. The EXPLAIN and PROFILE runs used PATINADB_CACHE_LIMIT=1GiB, except the one shown without a cache limit. Example D and the factorized, CSR and parallel excerpts also ran under a 2 GiB query memory scope. Each switch combination was a separate process. Every process except the edge-sorted-index run (switches off) ran CALL patinadb.stats.degrees() first.

CREATE INDEX ON :Person(id);
CREATE INDEX ON :Tag(id);
CREATE INDEX ON :Country(id);
CREATE INDEX ON :City(id);
UNWIND range(1, 5) AS i CREATE (:Place:Country {id: i, name: 'country' + toString(i)});
UNWIND range(1, 25) AS i MATCH (co:Country {id: (i % 5) + 1}) CREATE (:Place:City {id: 100 + i, name: 'city' + toString(i)})-[:IS_PART_OF]->(co);
UNWIND range(1, 1000) AS i MATCH (c:City {id: 100 + (i % 25) + 1}) CREATE (:Person {id: i, firstName: ['Chau', 'Ali', 'Jun', 'Eva'][i % 4], lastName: 'L' + toString(i % 17), creationDate: 1300000000000 + i * 1000000})-[:IS_LOCATED_IN]->(c);
MATCH (a:Person) UNWIND [5, 10, 15, 29, 61] AS d MATCH (b:Person {id: ((a.id + d - 1) % 1000) + 1}) CREATE (a)-[:KNOWS {creationDate: 1300000000000 + a.id * d}]->(b);
MATCH (a:Person) WHERE a.id <= 10 UNWIND range(1, 150) AS d MATCH (b:Person {id: ((a.id + d * 3 + 100) % 1000) + 1}) CREATE (a)-[:KNOWS {creationDate: 1300000000000 + d}]->(b);
UNWIND range(1, 40) AS i CREATE (:Tag {id: i, name: 'tag' + toString(i)});
MATCH (p:Person) UNWIND [0, 17] AS k MATCH (t:Tag {id: ((p.id + k) % 40) + 1}) CREATE (p)-[:HAS_INTEREST]->(t);
MATCH (p:Person) UNWIND range(1, 10) AS k CREATE (:Message:Post {id: p.id * 100 + k, creationDate: 1300000000000 + (p.id * 37 + k * 1009) * 100000, content: 'post ' + toString(k)})-[:HAS_CREATOR]->(p);
MATCH (m:Post) WITH m MATCH (t:Tag {id: (m.id % 40) + 1}) CREATE (m)-[:HAS_TAG]->(t);
MATCH (m:Post) UNWIND [3, 11] AS d MATCH (p:Person {id: ((m.id * d) % 1000) + 1}) CREATE (c:Message:Comment {id: m.id * 10 + d, creationDate: m.creationDate + d * 1000, content: 'comment'})-[:HAS_CREATOR]->(p), (c)-[:REPLY_OF]->(m);
MATCH (c:Comment) WITH c MATCH (t:Tag {id: (c.id % 40) + 1}) CREATE (c)-[:HAS_TAG]->(t);
MATCH (m:Message) WHERE m.id % 3 = 0 WITH m MATCH (p:Person {id: ((m.id * 13) % 1000) + 1}) CREATE (p)-[:LIKES]->(m);

KNOWS offsets 5, 10 and 15 form triangles. Persons 1–10 each get 150 extra outgoing KNOWS edges (hubs). Message is the primary label of every post and comment.

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.

Start with How the Query Planner Works. That chapter is the structured reference: the planning pipeline, entry and join choice, statistics, the experimental RFC 0042 switches and how to read the Decisions: section. This chapter keeps the detailed per-feature notes.

Immediate IN operands

When an IN predicate has a single parameter or literal as its right-hand operand, execution borrows that value for the membership check, including inside compound Boolean predicates. The source value remains owned by the statement or plan. This avoids making a new owned RHS copy for every candidate row. Row-dependent RHS expressions continue through the ordinary evaluator. Query memory limits still apply to owned inputs, intermediate work and results; this behavior introduces no configuration switch or higher limit.

WHERE placement

A WHERE comparison of a property with a literal or parameter is checked inside the pattern itself. This applies in either operand order ($d > m.date is the same as m.date < $d), in chained comparisons, and in every MATCH after a WITH. It applies only to the MATCH clauses written after the last UNWIND, CALL or LOAD CSV before the WHERE, so a filter never skips an error raised by such a clause.

WHERE n:Label on a node of a required pattern becomes a pattern label, so the label can choose the scan. A comparison of a single relationship’s property with a literal or parameter (WHERE r.since > $d) is checked while the relationship is expanded; a failure to read that property fails the query rather than dropping the row.

In a MATCH with several comma-separated patterns, a condition that reads only one of the joined patterns runs on that pattern before the next one is expanded. This covers =, <> and IS [NOT] NULL on its variables, properties, literals and parameters, combined with NOT, AND and OR. Conditions that can raise an error stay where they were written.

Reading EXPLAIN and PROFILE

For a direct property projection, RETURN n.key AS key ORDER BY key can use the same ordered property scan as ORDER BY n.key for an eligible single-node shape, subject to index/population safety checks. Traversals, DISTINCT and multi-key ordering keep their previous scan hints and post-sort behavior. Computed aliases such as toLower(n.key) do not imply that the original property’s index order is usable. An ascending STARTS WITH scan can seek to the prefix lower bound while retaining residual filters before applying the output window. Deep SKIP still requires advancing past qualifying rows; use keyset pagination when the application can express a stable cursor. These are generic planner rules, not special cases for ticket IDs.

EXPLAIN renders the chosen plan without running the query; PROFILE runs it and prefixes the elapsed time. For buffered Bolt EXPLAIN/PROFILE requests outside an explicit transaction, the effective client/server query deadline covers the complete execution, including time waiting for a blocking worker. Native execution runs off the async connection thread. Deadline enforcement remains cooperative; it cannot interrupt every individual storage call. No separate profiling timeout is used.

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 describes the planned physical rules from the shared registry. Runtime admission and preparation can still retain another path; use PROFILE’s executed-access observations to distinguish the actual choice from the plan. 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.
limit.reverse_probe (fanout est.N → probe est.M)Same shape as above with no edge-sorted index: instead of expanding the whole adjacency and sorting it, the planner walks the target label’s value index in sort order and probes each candidate for the edge, stopping once LIMIT is filled. Cost-chosen against limit.bounded_topk (the two estimates are shown), and never preferred over a real edge-sorted index.

Choosing an equality property

For an unsorted pattern with several eligible equality properties, the property rule compares bounded index probes instead of using the first property in the query. For example, a shared run_id need not drive every lookup when id identifies a much smaller set. The selected predicate and all other conditions are still enforced, including repeated conditions on the same property.

Each candidate probe shares a raw-key budget across its numeric representations and any overlay source and local entries. Probes that reach the limit tie by field name and encoded value; they do not compute a full distribution. The estimates use committed data and can omit pending buffered writes. This can change which plan is preferred, but execution still reads its own writes. These estimates never prove that a result is absent. Existing UUID, IN-list and sorted access rules retain their precedence. EXPLAIN can separately collect its existing cardinality estimates from the statistics catalog.

For label-only entry selection, a zero count in the primary-label index does not prove that a registered label is empty: vertices may carry it as an additional label. In that case the planner uses a coarse source-total estimate, or an unknown estimate when the source total is zero. A label absent from the registry can still estimate zero. This changes plan choice, not matching or result completeness. Positive primary-label estimates are retained and may still underestimate labels that also have secondary holders. There is no new setting for this fallback.

Secondary-label decoding keeps up to 16 successful registry ID-to-label mappings per storage manager in fixed inline slots. A slot collision uses the ordinary registry lookup; missing IDs and errors are not cached. The memo is local to its storage view and remains valid across sync and graph clearing because registry IDs are never recycled. It does not cache a vertex’s current label membership. There is no new configuration setting; query memory limits and error handling are unchanged.

For multi-pattern inner joins, the planner can check an unestimated fixed-length path earlier when at least two distinct nodes in that path are already bound, or when it is a closed path whose starting/closing node is already bound. It uses the existing seeded nested-loop execution instead of building the entire unbound path. The initial driving pattern stays unchanged. Variable-length and open single-anchor paths do not qualify for this rule. This is a heuristic: unknown fan-out can still make a plan expensive, and no latency bound is implied. It adds no setting and does not change matching, edge uniqueness, or query deadlines.

The statistics catalog

The planner keeps a lazy, cached statistics catalog. Per label it holds a vertex count, taken from the primary-label index. Per property it holds column statistics from the value index: the present count, distinct-value count (NDV), min/max and a 16-bucket equi-depth histogram. CALL patinadb.stats computes the same quantities exactly and fresh, including secondary-label holders, so its output can differ from the planner’s cached, primary-label-only estimates:

CALL patinadb.stats('Person')

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

propertycountndvminmax
(label)3———
active32falsetrue
age333240
name33AdaGrace

In the procedure output the label summary row has a NULL property. The catalog is computed on demand and cached in RAM only. Each entry is tagged with its label’s write generation and served until it is more than PATINADB_STATS_STALENESS_BUDGET generations (default 64) old. A generation advances once per dirty label per storage synchronisation. Because statistics only change which plan runs, never the result, they are never replicated across a cluster; each node computes its own. The degree catalog and freshness rules are described in How the Query Planner Works.

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.

On valid data, join order and access-path changes preserve the result multiset. Different access paths can visit different records and change storage-error order, or whether corruption in an unrelated record is observed. Errors from accessed records still propagate. A query without ORDER BY 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. Eligible secondary-label matches use primary-label value seeks and membership checks; larger label/probe counts retain the secondary scan.
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 pagination — O(log N + k) value-cursor seek.
ORDER BY a, b [… ASC/DESC mixed] [LIMIT k] (single label, stats-gated)Covering-compound (same direction) or leading-key partial-prefix scan (any direction mix) — 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.
WHERE NOT EXISTS { (n)-[:R]->(m) } (single hop, no inner WHERE)Anti-join memo (exists.anti_join) — the existence verdict is answered once per distinct anchor node, not once per outer row.
RETURN n.p, count(*) grouped by n.pGroup-by-count run-length value-index scan.
MATCH (n:L) RETURN count(n) / count(*) (no WHERE)Label-count key-only label-index count (agg.label_count_scan) — no vertex reads at all. Declines when L is also used as a secondary label.
MATCH ()-[r]->() RETURN count(r) / count(*)Exact global relationship count (agg.global_directed_edge_count): reads current forward-table cardinality for a bare directed one-hop pattern. Filters, labels, grouping, DISTINCT, repeated aliases, overlays and explicit liveness probing retain ordinary execution.
MATCH ()-[r:T]->() RETURN count(r) (also T1|T2)Typed relationship count (agg.typed_directed_edge_count): one key-only pass over the forward edge table comparing the stored type id, without the all-node scan or per-node seeks. Still proportional to the total number of relationships (unlike a maintained count store); the same pattern restrictions as the global count apply.
Fixed linear MATCH with unequal terminal nodes, RETURN count(*)Terminal inequality count (agg.endpoint_inequality_count) for supported paths: streams interior matches and subtracts equal endpoint identities from terminal counts. Requires query memory admission and a proof of relationship uniqueness; unsupported predicates/shapes retain ordinary execution. It may reduce row work without reducing every kind of read; no general latency improvement is guaranteed.
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

Reducible aggregates consume input incrementally; their group state and DISTINCT/collect buffers remain bounded. Blocking operators (non-top-K ORDER BY, UNION-distinct, and hash-join build sides), plus the row expansion of UNWIND inside a correlated subquery, pattern-comprehension result lists, and each partial-prefix sorting group (including the NULL group), 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.

Subquery planning and warnings

Scalar expressions share recursive planning in RETURN, WITH, WHERE and function arguments. This includes COUNT/EXISTS nested in lists, maps, CASE, list comprehensions, REDUCE and ALL/ANY/NONE/SINGLE. Iterator/accumulator variables are local: [x IN [1,2] | x] uses its own x even if an outer x exists. NULL/empty-list quantifier semantics and short-circuiting are preserved.

For eligible single-pattern bodies, COUNT counts matches incrementally and EXISTS stops on a successful match. Complex WITH/RETURN tails can still use bounded materialization; pattern comprehensions collect bounded result lists. Subquery storage errors, query timeouts and memory-limit errors fail the query; they are not zero counts, false existence results or partial lists. Missing properties still yield NULL; failed property reads fail the query, including dynamic/entity-valued access and traversal target-property filters. Unused CASE/coalesce branches do not perform those reads.

EXPLAIN and PROFILE append deduplicated Warning [code] lines:

CodeExecution boundaryPossible change to evaluate
PDB1230-COUNTCOUNT retains per-input execution outside eligible projection reuse and bounded equality grouping; no general decorrelationUse EXISTS/NOT EXISTS if only existence is needed. For repeated exact counts, consider a grouped CALL/join.
PDB1230-EXISTSAn EXISTS body with operators beyond a scan and optional filter materializes its bounded subplan resultFilter outer rows first. Simplify to a pattern only when aggregation, pagination, errors and empty-input behavior are preserved.
PDB1230-COLLECTPattern comprehension materializes a list per input rowConsider COUNT or EXISTS with the same pattern and filter only if discarding projection evaluation is acceptable: projection errors and volatile expressions can change behavior. Use MATCH to return streamed rows if that result shape is suitable.
PDB1230-APPLYIdentifies either bounded reuse eligibility for identical proven CALL dependencies, or complete declared imports when unproved (complete inputs for internal APPLY) or a plan not proven repeatable; distinct inputs still execute per rowFilter the outer input first; deduplicate keys only when duplicate outer rows are not part of the required result.

Eligible repeatable serial projections reuse complete projected rows using the union of their proven input dependencies. Changing an unused outer column does not force another COUNT evaluation. Changing a returned column remains relevant to the key even if the COUNT itself depends on fewer columns. This is whole-row projection reuse, not independent scalar-subquery caching or grouped decorrelation. Unknown dependency forms (including wildcard subplans and opaque expressions) retain the complete typed input key. The existing row and 8 MiB serialized-data budgets apply; key serialization is bounded and does not clone input values.

Within an eligible projection, filter, aggregation, sort or UNWIND source, each COUNT expression may also cache its own integer result using its proven typed input dependencies. This includes graph patterns and trailing subplans, even when unrelated output columns change. Unknown dependency forms keep full input keys. Reuse is scoped to one operator and snapshot; faults are not cached. Volatile or unproven effects disable it. Each retained scalar entry charges its encoded key plus 264 bytes; all scalar entries and equality tables below share one 8 MiB auxiliary accounting budget and entry cap. Oversized keys and exhausted budgets keep ordinary execution; already retained entries remain usable. Different typed keys still execute the inner plan unless the independent equality-scan path below applies; otherwise mostly-distinct inputs can pay overhead without a benefit.

Boolean wrappers also retain native planned contents: XOR and scalar expressions used as predicates do not hide nested COUNT from these contexts. Predicate values must be Boolean or NULL; other types raise an argument-type error. XOR evaluates both operands once, left to right, while enclosing AND/OR and CASE retain their short-circuit behavior. This does not make volatile expressions reusable.

A snapshot-proven projection, filter, aggregation, sort or UNWIND source may also retain equality counts for COUNT { (n) WHERE n.property = key } expressions (either equality order), including nested arithmetic, functions, CASE, lists/maps, indexing/slicing, list predicates/comprehensions, REDUCE and pattern-comprehension expressions. Here key may be a parameter or a snapshot-stable expression over outer bindings, including node/relationship or map properties, arithmetic and containers. The dependency proof must exclude every inner binding; unknown or volatile expressions retain ordinary execution. Direct id()/elementId() comparison operands retain their special validation/coercion path. On an empty inner relation the key is not evaluated, including later table lookups. On the first nonempty scan its evaluation preserves operand error order; later key errors still propagate through the evaluator. The body may be a single-node scan or a graph path such as COUNT { (n)-[:R]->(m) WHERE n.property = key }. Any named node or relationship in that path may supply the compared property; a proven snapshot-stable inner-only expression such as n.v + 1 or n.v + m.v is also eligible. Its dependencies must be known and contained entirely in the inner path. Arithmetic, functions and containers use the existing expression evaluator in original operand order. Expressions mixing inner and outer bindings, volatile expressions and direct id()/elementId() operands retain ordinary execution. A failed evaluation never populates the retained table. Paths may span multiple fixed or variable-length relationships, including zero-length and unbounded ranges. They use the original traversal and preserve path multiplicity; this does not bound the cost of the first traversal. All named nodes, relationships and internal positional path bindings must be independent of outer bindings, with no repeated names, path binding, optional match, dynamic property constraints, explicit seek hints or trailing clauses. Literal and parameter property filters on nodes and relationships are eligible; the original match iterator still applies them. A leading AND prefix such as n.active AND n.v=key is eligible only when its dependencies are proven inner-only and snapshot-stable. It runs before the equality in the original order: false skips both comparison operands, whereas NULL still evaluates them for errors but contributes no count. A prefix error prevents retaining the table. All-false prefixes also skip lookup-key evaluation on reuse. Trailing, volatile or outer-dependent predicates keep ordinary execution; the planner does not reorder them to force admission. Directions and match multiplicity use the same match iterator as ordinary execution. The first required scan computes its normal answer while building auxiliary counts; subsequent supported scalar keys may reuse them even when other output columns change. Empty outer input does no inner scan. Indexed and seeded patterns retain their existing access paths. Complex inner plans retain their normal execution. Each expression uses its current lexical bindings; no COUNT is evaluated before its branch or loop reaches it. All auxiliary equality tables in one operator share the entry and byte budgets below; this does not grant a fresh budget per nested COUNT. Reused lookups retain cancellation checks within a single row.

Filter contexts cover ordinary WHERE, seeded CALL execution, streaming COUNT predicates and both simple EXISTS predicate loops. Hoisted IN membership retains its prepared candidate set and uses the same context for its COUNT needle. An inner loop owns its context for that invocation; it never shares results with another CALL import or snapshot. EXISTS still stops at its first matching candidate, and short-circuited branches are not evaluated early. Volatile predicates disable reuse. Serial aggregation shares its context across group-key computation, aggregate arguments (including collect with entity preservation), and group-key output. All expressions share one auxiliary budget for that operator, rather than one budget per group. Its existing group and collected-value limits still apply. Parallel aggregation gives each worker its own context on the pinned snapshot; group-key finalization uses a separate context on that same snapshot. Budgets apply per worker, with separate later finalization accounting; 8 MiB is not a global query heap limit. No cache crosses workers or query invocations. Full sort and bounded top-K retain one context across key-computation windows, including seeded CALL sorting. Input and all sort expressions must pass the effect proof; unknown callers retain ordinary evaluation. Stable tie order, NULL ordering and the existing heap/input limits are unchanged. Mostly-distinct keys can cost more; use PROFILE and representative measurements. This is bounded reuse, not general decorrelation.

UNWIND prepares function, list and general expression sources once in the plan. Each source keeps a bounded context across seeds within one repeatable APPLY execution; nested invocations and snapshots do not share it. Literal and plain variable sources retain their existing resolution rules. List entity values, NULL expansion, lazy errors and the existing APPLY/UNWIND row caps are preserved. Procedure arguments and unproven effects retain ordinary evaluation.

The table keeps exact integer keys separate from floating-point comparison buckets, preserving existing mixed numeric equality, including large integers. NULL and NaN do not match. Unsupported inner values or exhausted auxiliary budgets discard the table and retain normal COUNT execution; unsupported outer keys use normal execution. The auxiliary budget charges 256 bytes per retained map entry plus string payload, up to 8 MiB and the aggregation row cap (integer keys can require two entries). This is accounting for retained state, not a measured total query heap bound, and is separate from the projection memo. The first scan incurs grouping work even if no later row reuses it. This is a bounded optimization for an admitted shape, not general decorrelation or a cost-based promise. See the measurement ledger.

Independent COUNT scans with additional repeatable predicates can retain their scan rows when an AND branch contains a proven inner/outer equality. The original complete predicate is evaluated for each outer row in source order; trailing errors, NULL reachability and outer-dependent conditions are not reordered. The first evaluation uses ordinary execution. The second may build a bounded scan cache; later evaluations can avoid graph traversal, but still decode rows and evaluate the filter. This is not a hash join or constant-time count lookup. The cache shares the context’s row/8 MiB auxiliary budget, charges serialized payload plus overhead, and is discarded on overflow or evaluation errors. Volatile or unproven effects, correlated scan bindings and unsupported scan shapes retain ordinary execution. Small input counts may not amortize cache construction; measure representative queries.

Individual repeatable EXISTS predicates also retain bounded Boolean results by proven typed dependencies, so changing unrelated outputs need not repeat them. Unknown dependencies use the full input row. The memo is owned by the same projection, filter, aggregation, sort or UNWIND context as COUNT and shares its entry/byte budget. Cache misses preserve the original edge probe, lazy pattern execution or bounded complex subplan; errors are not stored. Hits still check cancellation, and separate CALL invocations, workers, writes and snapshots retain their existing context boundaries. Distinct keys can add overhead without reducing execution. This is bounded reuse, not a streaming rewrite of complex EXISTS tails.

These are static plan diagnostics, including conditional expression branches; they are not measured cost estimates or automatic rewrites. They appear in plan text, not as a new Bolt notification protocol. Check PROFILE on representative data and preserve zero counts, NULLs, grouping, duplicates and ordering. For example, EXISTS { (n:Absent) RETURN count(*) AS c } is true even when there are no matching nodes: the inner aggregate still returns one row. Removing that aggregate changes the answer. Grouping by an inner property instead returns no row on empty input. A projected NULL still counts as a row for EXISTS; DISTINCT and WITH-level SKIP/LIMIT can remove that row. Complex tails retain reached projection errors even after an earlier row could establish existence; an enclosing unchosen CASE branch still skips the entire subquery. Do not drop those projections or stop early without proving error/effect preservation. A missing warning does not prove that every runtime guard admits an optimized path.

For an independent count reused across rows, a unit CALL expresses computing it once explicitly:

CALL { MATCH (m:Metric) RETURN count(*) AS total }
MATCH (n:Customer)
RETURN n, total

A correlated count is not interchangeable with that query. COUNT/EXISTS bodies accept one pattern and optional WHERE followed by WITH/RETURN stages. DISTINCT, filters, aggregation and LIMIT affect the final row count or existence. These stages retain the normal intermediate-result bounds. Eligible repeatable expressions may reuse results for repeated bindings within one operator. Use CALL for other supported inner-query forms, including UNION. General automatic decorrelation remains tracked in #1230.

CALL subqueries can combine RETURN branches with UNION or UNION ALL, including imported outer bindings. UNION removes duplicates; UNION ALL preserves them. When ORDER BY repeats a projected aggregate, its DISTINCT modifier is part of the aggregate identity: count(x) and count(DISTINCT x) may sort differently. The same identity applies inside scalar sort expressions such as abs(count(DISTINCT x)) or count(x)+1. Sorting by the explicit aggregate alias also selects the intended result. Unaliased aggregate output names such as count(n.value) are treated as exact column names when reused in these expressions.

WITH DISTINCT inside CALL applies before later aggregates, and WITH * can carry bindings alongside explicitly calculated aliases. These paths retain bounded intermediate results; using CALL does not imply automatic decorrelation.

Equality filters inside COUNT, EXISTS and pattern comprehensions can use property indexes, including $parameters and bare outer values (n.key = wanted or wanted = n.key). The supported combination is a conjunction of simple equalities. Complex filters and outer property expressions retain their general path; this avoids changing evaluation errors. The filter is still checked after the index lookup. This improves access per outer row and does not imply that the subquery has been decorrelated or cached across rows.

coalesce() evaluates arguments from left to right and stops at the first non-NULL value. A later subquery fallback is therefore not executed when an earlier value is present. False and empty lists count as present values. Errors in an argument that is actually needed still fail the query.

Correlated relationship bindings

COUNT, EXISTS, pattern comprehensions and CALL retain the identity of an outer relationship reused by name in an inner pattern. OPTIONAL MATCH candidate caching includes bound relationships and resolved scalar seek values. Rejected candidates do not consume the materialized-row limit, but still count toward query deadline checks. A relationship identity filter can still require scanning candidate edges; it does not imply general decorrelation or a direct edge seek.

A sorted scan inside correlated CALL also honors SKIP, including parameterized offsets. It applies the offset after binding validation and before bounding the collected rows. This path may visit the skipped candidates, unlike standalone key-only index pagination; an index does not guarantee identical pagination cost inside and outside CALL.

Bounded reuse of repeated CALL inputs

Within one APPLY execution and storage snapshot, deterministic read plans can reuse the result for identical declared CALL imports. Other outer columns do not enter a CALL scope or its memo key. CALL without imports has an empty seed and can reuse one result when outer rows exist. Internal planner APPLY retains the complete outer row as its dependency key. Outer duplicates and ordering remain present in the output; empty inner results remain empty. Keys preserve runtime types and representations, so Integer and Float inputs do not share a memo entry merely because Cypher considers their values equal.

The memo holds at most the configured row limit in entries and result rows, and at most 8 MiB of encoded keys/results. Oversized inputs or a full memo keep ordinary execution. Query deadlines, output limits and evaluation errors still apply. Each APPLY invocation owns its memo; it is not reused across queries, parameter changes or transactions.

Admission uses a snapshot-local effect analysis, separate from cross-transaction cache invalidation rules. It recursively covers read-only COUNT/EXISTS, list predicates/comprehensions, CASE, REDUCE, OPTIONAL MATCH, UNION and nested CALL, including unlabeled nodes and untyped relationships. Executable pattern and seek expressions are checked too. Procedures, writes, external input, volatile functions and unproved expression shapes remain excluded. Shortest-path escape hatches currently retain ordinary execution. An explicit non-wildcard output projection allows proven unused imports to be omitted from the memo key. Unknown dependencies, wildcard output and unsealed output retain all imports. Different required imported values still execute separately; this is not general grouped decorrelation. EXPLAIN/PROFILE identifies that remaining work and suggests filtering the outer input or a semantic-preserving grouped rewrite where appropriate.

When APPLY reuse is excluded, additional plan diagnostics identify the cause:

DiagnosticCause and suggested action
PDB1230-VOLATILERandom/clock-dependent evaluation; bind once outside CALL only if one value per query is intended.
PDB1230-PROCEDUREProcedure effects are unproved; batching or deduplication must preserve invocation effects.
PDB1230-EXTERNALExternal input may change; read once only if a single input snapshot is intended.
PDB1230-MUTATIONWrites cannot reuse prior results; reducing duplicate writes changes semantics unless intended.
PDB1230-PROOFUnsupported proof shape; retain per-row semantics and use PROFILE before a grouped rewrite.

These messages do not recommend silently replacing per-row randomness, external reads or writes with a once-only operation.

Aggregate expressions in lists, maps, index/slice expressions and CASE conditions are evaluated at their enclosing WITH or RETURN grouping stage. For example, UNWIND [1,2,3] AS x RETURN [count(*)][0] AS c returns one row with c = 3. Aggregates within an inner COUNT/EXISTS query keep the inner scope.

Aggregates in iterator sources belong to that enclosing grouping stage too: UNWIND [1,2,3] AS x RETURN size([y IN collect(x) | y]) AS n returns n = 3. REDUCE may consume an aggregate list and use an aggregated initial value. Local iterator variables are available in the body; other outer references must be valid grouping keys when the expression aggregates.

Comprehensions, REDUCE and ANY/ALL/NONE/SINGLE accept the same scalar source expressions, including direct list parameters, slices, CASE, nested comprehensions and COALESCE. For example, [x IN $items[0..10] | x] does not require an intermediate WITH solely to name its source. NULL and empty-list behavior is unchanged.

Introspection functions evaluate their argument first: keys(box.value) and properties(box.value) inspect the selected value, not box. Entity-valued fields retain their node/relationship identity for id, elementId, labels, type, startNode and endNode.

Runtime argument errors inside iterator bodies fail the query. type() accepts a relationship or NULL; a node or other non-relationship is an argument error. Strict toBoolean/toInteger/toFloat/toString reject list, map and graph-entity/path arguments. Use the corresponding ...OrNull function when NULL on unsupported types is intended. This is distinct from an unparseable conversion string, which can still return NULL.

Inline aggregates in iterator bodies (for example [x IN [1] | count(*)]) are a planning error, including when the input is empty. Compute the aggregate in a preceding WITH and reference its value in the iterator, or use an explicit COUNT/EXISTS subquery with its own grouping scope. Aggregate sources such as [x IN collect(n) | x] remain supported.

Within an aggregating CASE expression, every reference outside aggregate arguments must be a projected grouping key. This applies to WHEN conditions, THEN/ELSE values and simple CASE scrutinees. For example, project x alongside CASE WHEN count(*) > 0 THEN x ELSE 0 END to group by x; without that grouping key the query raises AmbiguousAggregationExpression.

Grouping aliases preserve the input variable’s value inside correlated COUNT/ EXISTS queries, pattern comprehensions, paths and boolean predicates. Local iterator variables retain lexical shadowing. Internal group/aggregate names are chosen to avoid user identifiers; using an alias that matches another input or local variable does not change that variable’s value.

A property-only grouping key such as n.v also retains that property inside correlated scalar subqueries. It does not retain the whole n or unrelated properties: using those in the same aggregating expression raises AmbiguousAggregationExpression. Project the additional grouping key only if its finer grouping is intended; otherwise compute the needed value before the aggregation. Grouping by the whole entity retains its identity and properties.

Dynamic properties inside scalar patterns

COUNT, EXISTS and pattern comprehensions evaluate dynamic pattern properties against the current row, including bindings, property accesses, arithmetic and functions. This also applies when COUNT or EXISTS has a WITH/RETURN tail. Anonymous elements remain anonymous in wildcard output and DISTINCT semantics. For a variable-length relationship, dynamic property conditions apply to every relationship in the path; a zero-length path satisfies that condition vacuously. Unreached CASE branches and empty candidate scans do not evaluate the property expression. Reached evaluation errors propagate. These correctness rules do not imply that a dynamic property pattern is eligible for grouped COUNT reuse.

Dynamic pattern properties

Dynamic node and relationship properties in MATCH are evaluated against the current row, including anonymous patterns such as MATCH (:Item {value: k}). Their internal bindings do not appear in wildcard results or change DISTINCT row identity. In OPTIONAL MATCH these conditions belong to the optional clause: when no candidate satisfies them, the incoming row remains. Variable-length relationship conditions apply to every edge; a zero-edge path has no edge condition to evaluate. As with an explicit WHERE expression, a reached invalid expression reports an error; absent candidates do not evaluate that expression.

RETURN DISTINCT * exposes the bindings still visible after the preceding clauses. ORDER BY can refer to those bindings and their properties, including within scalar subquery expressions. A name removed by WITH or never introduced is still unavailable; an explicit DISTINCT projection retains only its projected columns for ordering.

Filters before later reading clauses

A required MATCH ... WHERE filter is applied before a following MATCH, OPTIONAL MATCH, UNWIND or CALL. Dynamic pattern properties obey the same boundary. A row discarded by the required clause does not evaluate later expressions; an error reached by a surviving row remains an error. Patterns separated by commas within one MATCH are evaluated together before its filter.

A literal equality selecting a single anchor before a separate traversal MATCH can still use a covering edge-sorted index for top-K and receive its index recommendation. Extra anchor predicates and unproven shapes retain ordinary execution; multiple matching anchors retain the existing fallback.

OPTIONAL followed by another reading clause

A following MATCH, UNWIND or CALL consumes the rows produced by the preceding OPTIONAL MATCH, including its NULL bindings. Required matches cannot bind those variables before the optional clause applies its own predicate. A missing optional node followed by a required relationship from that node produces no rows; an independent required pattern can still combine with the NULL row.

Reading order before MATCH

A preceding UNWIND or CALL executes before the subsequent MATCH. For example, UNWIND [1/0] AS k MATCH (n:Missing) RETURN k reports the arithmetic error even when the later label has no matches. Empty prefixes still produce no downstream rows, and duplicate prefix rows retain their multiplicity. Explicit WITH is not required to preserve this boundary.

OPTIONAL clauses with multiple patterns

Comma-separated patterns inside one OPTIONAL MATCH must all match and satisfy the clause’s WHERE. Otherwise the input row survives once, with all newly introduced bindings set to NULL; existing outer bindings retain their values. Two separate OPTIONAL MATCH clauses are evaluated independently in order.

Relationship identity across patterns

Within one MATCH or OPTIONAL MATCH clause, different relationship occurrences must use distinct physical relationships, including across comma-separated patterns, anonymous relationships, named paths and variable-length paths. Separate MATCH clauses may reuse a relationship. A zero-length path contributes no relationships to this check. An OPTIONAL clause without a complete valid match retains its incoming row with new bindings set to NULL.

Internal bindings used for these checks are not exposed by wildcard projections. Repeated occurrences of the same node variable, such as a self-loop’s endpoints, appear once in RETURN * and WITH *, including DISTINCT projections. The existing intermediate row and cartesian-product limits still apply.

The relationship uniqueness check completes at the MATCH clause boundary, before subsequent UNWIND, CALL or OPTIONAL MATCH clauses run. Rows rejected by that check do not evaluate expressions in those later clauses. Errors on valid matching rows remain observable.

Automatic integer covering input (#1399)

A broad plain single-label aggregate reading one integer property automatically uses a covering value-index scan when the shape, row estimate and scratch budget pass admission. Stores/inputs estimated below 4096 rows retain existing access. Configured parallel workers also retain existing aggregate admission; this initial policy does not compare covering against parallel throughput. A bounded external merge restores the original UUID order before the existing evaluator consumes values. This avoids changing SUM intermediate overflow, floating-point expression accumulation, group encounter order or DISTINCT behavior merely because an index sorts by value. Missing properties retain NULL semantics. Original group/buffer limits still apply.

The candidate declines floats (index encoding canonicalizes signed zero), wide values, secondary labels, pending derived indexes, copy-on-read mirrors, selective patterns and unproved expressions. Existing grouped-count index execution keeps precedence. EXPLAIN exposes the selected candidate or its fallback reason, including estimated reorder work and insufficient scratch. Encoded key bytes exclude page overhead; current value-index payloads are empty, and primary I/O costs remain unknown. A logical byte estimate is not a prediction of physical reads. Limited synthetic memory-pressure qualification covers this narrow increment; cost selection remains heuristic and broader predicate/type coverage is follow-up work. PATINADB_READ_COVERING=off disables covering for diagnosis. See the configuration controls.

EXPLAIN describes the proposed access without running the query. PROFILE keeps that pre-execution plan and appends Executed aggregate access (query thread), collected at runtime. For example, eligible execution reports agg.covering_integer_uuid with the actual index-record count and reorder=memory or reorder=external merge. Retained execution reports a covering-decline reason and agg.existing_input (serial aggregate) or (parallel aggregate). Existing grouped/count fast paths report their own agg.* name.

The planned choice can differ from execution: an underestimated scratch budget or unavailable scratch during preparation falls back before emitting rows. PROFILE reports that fallback rather than claiming the proposed covering path ran. Index/storage errors, cancellation and duplicate/orphan identities are not converted into successful results. Diagnostics record at most 64 aggregate events, followed by an omitted-event count; they are not per-worker or per-row timings. For a query without observed aggregation the output says so explicitly. elapsed_ms still measures execution, including abandoned preparation, not EXPLAIN planning.

Bounded preparation for filtered aggregates (#1399)

Eligible residual string predicates can obtain candidate node IDs from the value index before aggregation. This preparation is chosen automatically; existing selective seeks keep precedence. It is separate from the integer covering input and its diagnostic switch.

The filter first reuses a prefix of at most 64 decoded records or 64 KiB of encoded keys, with one final key allowed to cross the byte threshold. Already cached column population and observed key width can predict preparation that would exceed its 64 MiB logical key-work budget. The policy never rebuilds a full statistics catalog just to decide admission. A value-sorted prefix is biased, so its match rate is extrapolated only when the first and last encoded values prove that the column is uniform. Missing column statistics retain runtime bounds; label cardinality is not substituted for property population. Stale estimates can change the selected path but do not supply query results.

For supported COUNT grouping, a small exact set of selected roots can keep ordinary property reads instead of preparing the full grouping column. The current heuristic applies to at most 16 selected roots, an estimated label with at least 1,024 nodes and a ratio greater than 64:1. A bounded width prefix can also reject estimated group preparation exceeding the existing 16 MiB retained-value budget. These thresholds are admission heuristics, not a calibrated I/O price.

EXPLAIN reports a planned bounded prefix probe, available advisory counts and budgets. It does not evaluate the predicate to measure selectivity. PROFILE’s executed-access section reports filter.covered_candidates and, when attempted, filter.group_values, with the selected path or fallback reason. Record/key-byte counters cover consumed preparation entries; they exclude metadata/domain probes, page metadata and physical I/O. Worker-local preparation is not included in the query-thread capture. Copy-on-read overlays and pending indexes retain existing access before creating the covering iterator.

Runtime candidate, retained-value, byte and deadline limits remain active even when estimates are too low. One large encoded value must still be read to inspect it; these bounds do not promise total process RSS. The policy can discard a bounded amount of preparation before fallback and does not guarantee that every filtered query becomes faster.

Inline and external vertex properties (#1399)

The logical primary key still begins with the owner UUID, preserving per-owner prefix reads. Schema 7 keeps canonical values up to 256 serialized bytes inline and places wider canonical values in a property/owner payload table. The owner entry carries a tag. Ordinary point reads and hydration resolve that tag using the same storage snapshot; writes update both tables in one redb transaction. This routing is automatic and has no activation flag. The threshold is a fixed layout heuristic, not a measured optimal boundary for every workload.

Keeping small values apart from wide neighboring values helps workloads that need a small property through primary access. Existing costed index/covering, selective and aggregate choices remain in place. The evaluator still decides which values to read and in which order. A property-ordered scan is not selected: its prototype did not demonstrate enough benefit to justify another access path. Broad reads of the wide values themselves are not guaranteed to improve; warm point/hydration overhead and write/index costs still matter.

EXPLAIN’s Vertex property storage line describes the physical representation, separately from the query operators and storage I/O policy. PROFILE adds Resolved vertex property storage: actual inline/external resolutions and their canonical byte counts on the query thread. These include point and range reads; worker threads, caches and buffered values may bypass them. They are not disk traffic, evaluator-call counts or a total-memory measurement.

The payload table retains canonical bytes, including nested values and exact floating-point representations. Missing external data fails as a storage error; it is not interpreted as a missing property. Value-index keys keep their full ordered value, and repeated wide values are not interned across owners. Schema 6 stores require recreation/reimport; no on-disk compatibility migration or dual decoder is included in this format change.

Wide value-index keys (#1399)

Value indexes keep the encoded property value in the key so equality, prefix, range and ordered scans retain their existing semantics. The authoritative property tree keeps the original value. The secondary payload is empty for both direct setters and batch/rebuild paths.

redb 4.3.0 abbreviates byte-key separators in internal B-tree pages where the neighboring keys permit it. Full keys remain in leaf pages: this is neither string interning nor removal of repeated leaf-key bytes. Long common prefixes can prevent useful shortening, and wide full-index scans still visit wide leaf data. This backend representation is automatic; EXPLAIN and PROFILE keep reporting the same logical access paths. No planner flag enables it.

Smaller indexes do not guarantee fewer physical reads for every access path. Constrained-memory qualification found primary scans that read more from disk with the new file layout, despite similar requested read volume. Account for that tradeoff when measuring a workload dominated by wide primary properties.

The final wide-key investigation retained this representation. A fixed-prefix multimap can save space but may scan a whole suffix group for a selective lookup or late page. A separate prefix dictionary restores suffix seeks, yet adds indirection that regressed distinct-wide reads in the native controls. Grouping complete identical values benefits duplicate-heavy data but does not shorten unique values. These experimental representations are not product options.

The source repository records the method and counterexamples in docs/wide-index-layout-decision-1399.md; earlier access-path work is in docs/read-path-1399-plan.md. This concludes the bounded optimization round, without claiming customer/TB qualification or universal optimality.

Primary-property I/O under memory pressure (#1399)

Logical access and aggregate ordering are unchanged. For an existing Linux store larger in allocated bytes than detected RAM, pure primary-property point reads can use a separate no-read-ahead file handle. This avoids applying the same hint to index/range scans or writes. The policy is selected at open and is reported by EXPLAIN’s Storage: line. Smaller stores and unsupported/unavailable platform facilities retain native I/O; PATINADB_PRIMARY_POINT_IO=off is a diagnostic override requiring reopen.

PROFILE’s Storage I/O footer distinguishes backend requests through the primary handle from native requests on the query thread. It excludes worker threads and redb cache hits and does not measure physical disk bytes. Cold latency can still trade off against speculative reads; this is not a universal scan speedup or a replacement for workload-specific measurements. The counters instrument the Linux file backend only; memory, simulation and other platform backends are uninstrumented, rather than evidence of zero I/O.

Fixed traversal to an already bound node

A fixed, single-type hop whose destination has UUID equality can test the typed edge key directly. Directed and undirected relationship semantics, edge properties, destination labels/properties and relationship uniqueness still apply. Other traversal shapes retain the general iterator. This rule has no configuration option; a speedup for a complete query is not yet established by the current LSQB work (PR #1410).

UUID point entry access also completes at the selected vertex: required labels are checked against that vertex’s primary and secondary labels, followed by all remaining property predicates. It does not append the general secondary label scan. Missing vertices yield no row; errors reading the candidate’s own labels or properties remain errors. Other entry access methods retain their existing candidate enumeration (PR #1410).

For property-free targets, traversal can reuse the primary-label ID already stored in the edge key, including targets without a label constraint and secondary-label checks. Secondary membership still has to be read and validated; this only removes a redundant primary-label lookup. Cached edges without an inline ID and property/multi-label constraints retain the full validation path. The existing optional liveness check remains in effect. No new setting or whole-query speedup guarantee follows from this access refinement.

Counting independent path branches

For eligible count(*) aggregates, agg.disjoint_path_count counts each side of a fixed path around a pivot and multiplies the counts. It avoids constructing every combination of the two sides. Each branch streams; count arithmetic checks for overflow and execution continues to enforce the query deadline.

Explicitly configured parallel query workers retain existing parallel admission. Eligibility requires one required path, no grouping or DISTINCT, and disjoint relationship types across the split. Binding names may not repeat. Optional or variable-length paths, property/seek constraints and path bindings retain the existing execution. A proven filter may move to one factor when every referenced binding is a vertex of that factor. Supported expressions are vertex = / <>, AND, NOT, and bare EXISTS for one fixed typed edge between bound vertices. The EXISTS form permits at most one label constraint per endpoint, but no local bindings, properties, seeks, named edges or additional scope. Cross-factor predicates, functions and other subquery forms retain the existing execution.

For the newly admitted compound or EXISTS predicates, the opposite factor is counted first. If it is empty, the predicate is not evaluated: there are no joined rows on which to evaluate it. Otherwise predicate evaluation retains ordinary bindings and fallible errors propagate immediately, without replay or fallback. Count products and sums remain checked; deadlines and existing limits remain. No isolated speedup is claimed for this extension.

For an eligible pure count factor with a fixed endpoint, typed single-edge EXISTS checks may reuse a neighbor set scoped to that factor. A complete set can prove absence; possible matches still use the ordinary point check. Duplicate paths retain their multiplicities. Set memory is charged to the query budget in addition to the existing pair memo, with a separate bounded scan cap. An incomplete capped scan is discarded and ordinary probes continue. Memory refusal, cancellation and read errors propagate without retry. Only live index views admit this optimization; overlay views retain ordinary probes. No new configuration option is introduced (PR #1410).

EXPLAIN reports the selected pivot as a path-element offset; PROFILE confirms actual use. Selection balances branch lengths without a graph-wide statistics scan. This is a heuristic, not a universal cheapest-plan guarantee. The original LSQB comparison at 928cd9ad completes Q1/Q6 with exact counts; other query regressions remain. This is not general performance qualification (PR #1410).

The development extension agg.grouped_disjoint_path_count applies the same factorization to one vertex grouping key at the pivot and one count(*). It retains the ordinary full-path entry choice, traverses that entire half, and counts its rows by the grouped vertex using the existing bounded aggregate. The opposite half is counted once per source group and its count is multiplied with the source multiplicity using checked arithmetic. Only the pivot binding is retained from each source row; duplicate paths still contribute to the count. Groups without an opposite match are removed before the eager result is exposed.

Existing group/output row bounds remain in force. Intermediate source groups can include vertices with no opposite match, so this strategy can refuse at the group cap even when the final group set would fit. It does not raise the cap or retry through another strategy after a resource error. Filters, scalar or multiple grouping keys, DISTINCT, optional paths and unproved sources retain the ordinary executor. Result order without ORDER BY may change; explicit sorting and limits retain their operators. PROFILE records the chosen entry and source-side grouping. The source-entry correction has focused correctness and work-count controls; no isolated speedup is claimed.

Eligible predicate-free COUNT branches reuse the ordinary traversal state machine while omitting terminal row payloads. Candidate checks, relationship uniqueness, label validation and deadlines still run for each match. Branches that evaluate a row predicate retain their bindings. Repeated bindings, middle-entry products, variable-length paths, optional/path bindings, property/seek constraints and active join guards retain ordinary row emission. This changes materialization, not cardinality or traversal semantics; no configuration toggle is added.

A development COUNT adapter can also compare two uniquely bound vertices for = or <> directly from their UUIDs during successful path emission. It uses the same traversal, target validation and relationship-uniqueness checks, and counts complete matches with the existing overflow checks. Unsupported bindings or expressions retain row evaluation before consuming the iterator. This avoids rows for that proven predicate; it does not skip graph validation or prune paths. Focused and restored Q6 controls plus three genuine mutations passed. The later PROFILE validation also passed the retained path-count tests after adaptive removal. Combined broad source validation passed (PR #1410). Ordinary-binary comparisons of the combined changes are recorded in the LSQB measurement ledger; those bundle measurements do not isolate this adapter’s contribution.

A development scalar COUNT alternative can choose an entry within the shorter independent factor when its primary-label cost estimate is smaller than the original pivot’s. It reuses the grouped admission proof, source_half split and shared traversal, then combines each pivot’s count with its opposite branch using checked arithmetic. Exactly one existing MemoryHashMap<Uuid, i64> retains the counts; its actual table allocation and growth consume statement memory credits. The existing aggregate group cap remains. No representative-row collection or second key set is retained. For a uniquely bound stable vertex, the source factor can emit its UUID directly through the same completed traversal rather than build a binding row only to extract that UUID. Admission requires the existing endpoint/fixed-path proof; unsupported shapes retain row evaluation before consuming input. Match order, duplicates and visited errors are preserved. This later source-emission change passed focused/restored and mutation controls. The later b338 combined-binary comparison includes this change; it does not isolate the gain from UUID emission.

Legacy calls without a statement memory scope retain the streaming path. Group bindings _, _anon and internal property bindings also decline before execution. Once execution starts, visited errors, cancellation and resource refusals propagate without replay. The additional retained groups can cause a memory refusal even where the streaming alternative would have completed.

Primary-label counts do not describe the full secondary-label population and are used only as cost heuristics. Cold planning uses bounded index probes, adding planning work; this does not prove a better traversal order. EXPLAIN shows the enclosing candidate, while successful PROFILE execution records agg.local_anchor_path_count with the chosen pivot and entry. Focused tests, the actual hook-bypass counterexample and restored-source lint checks passed. Combined broad source validation and the b338 ordinary-binary comparison are recorded in the measurement ledger. The comparison is descriptive, uses shared OS cache and includes other changes; it does not establish a universal benefit or performance under RAM pressure (PR #1410).

The experimental bounded suffix-count memo was removed after its ordinary-binary comparison established no benefit. Scalar counts retain direct branch traversal and the general row-elision paths described above. Historical measurements are recorded in docs/lsqb-sf01-coverage-2026-10-11.md; the experiment is not a current execution strategy or configuration option.

Grouping consumes owned evaluated keys without copying their heap-backed payload a second time. Borrowed callers retain copying behavior. Numeric equivalence, NULL grouping, entity identity and first-occurrence output representatives remain unchanged. This removes one copy; it does not make grouping allocation-free or remove its existing memory and group limits. No isolated speedup is claimed for these materialization changes.

Closed paths and endpoint joins

The development planner preserves a join alternative for two fixed paths sharing both distinct endpoint bindings when each relationship has one explicit type and the type sets do not overlap. The normal join optimizer still chooses execution order and hash versus seeded execution. Clause-wide relationship identity checks remain. Open chains and shared, untyped or variable relationship domains keep the existing fold policy. The original LSQB Q2 comparison at 83002192 still selects seeded execution and times out. Preserving a join alternative alone does not establish improved performance (PR #1410).

The single-probe cost comparison no longer selects repeated seeded execution for multi-hop paths with unbound interior vertices: it does not estimate the intermediate traversal work. Such cases retain hash execution unless the separate policy for unknown anchored paths applies. One-edge and fully bound interior paths retain their existing cost comparison. Performance validation of this development refinement is pending (PR #1410).

Streaming APPLY and partial join counts

APPLY streams its results one outer seed at a time when the snapshot-read analysis proves the entire consumer plan repeatable. Recursive iterator construction inherits that decision; unproven procedures inside scalar COUNT/EXISTS keep the eager read boundary. The outer input and each seed’s result are still collected, and nested consumers that require rows still collect them. Cumulative output limits and deadlines remain. Unproven procedures, mutations, external input and volatile expressions keep eager evaluation, including before a downstream LIMIT or early iterator drop. A downstream LIMIT or early drop can avoid evaluating later pure-read seeds; errors or cap breaches confined to those unconsumed seeds are therefore not reported. Errors encountered in the consumed prefix still propagate. This is lazy read evaluation, not preservation of the former eager error timing.

After ordinary storage-aware join ordering, eligible ungrouped count(*) over an inner join can group both inputs by their shared vertex keys, join those counts, and sum their products. This preserves multiplicities while avoiding materializing the corresponding cross-product rows. Scalar keys, DISTINCT, optional matches, user grouping, intervening filters and unproved source plans are excluded. A selected APPLY plan is retained. Existing group and memory caps apply; changed physical retention can change when resource refusal occurs.

Relationship identity guards between comma-separated patterns are omitted when their explicit relationship type domains are disjoint. Overlapping alternatives and wildcard types keep their guards. No graph label names or benchmark query texts select these rules.

Exact join membership during fixed-path traversal

For structurally pure keyed inner joins over a correlated fixed path, the executor can reuse the existing join hash table to reject vertices that cannot contribute a result before expanding their remaining path. The original join still emits every matching row, preserving duplicate witnesses. This requires proven vertex bindings and ordinary variable identity; anonymous internal names, scalar keys, optional paths, variable-length paths and predicate/effectful boundaries retain ordinary execution. No graph label or benchmark name selects this optimization.

The filter shares the join table instead of building another set of keys. Additional fixed control structures are charged to the query memory scope; the existing join-table accounting remains unchanged. At most eight keys and eight filters are admitted; larger shapes use the ordinary executor. There is no configuration toggle. Deadline and memory limits are unchanged. Work pruned before expansion performs no downstream reads, so errors in those unvisited branches are not evaluated. Errors from consumed storage reads still propagate.

The extension join.grouped_identity_membership also checks an identity vertex grouping key between two eligible grouped COUNT(*) inputs. Both inputs must satisfy the fixed-path grouped-count proof, and the inner join must use their single output key. Output aliases are supported; anonymous/internal bindings, inherited membership filters and effectful consumers retain ordinary execution. It borrows the already built right join table while evaluating the left aggregate, after source-side grouping and before counting each group’s opposite branch. Source rows and group buffers are still constructed; only the unmatched opposite-branch work is saved. Counts for retained groups and the original join’s multiplicity are unchanged. A deadline check precedes every membership decision, including empty right inputs. Encountered source, allocation and traversal errors propagate; skipped opposite branches do not evaluate potential storage errors or count overflow.

Fixed-hop storage errors

Fixed-hop traversal preserves upstream adjacency storage and decoding errors through relationship-label and property filters. Such an error fails the query rather than silently removing the affected row. This also applies when grouped COUNT uses the same traversal. Existing property-lookup and missing-target handling are unchanged.

Secondary-label membership scans

Single-label scans of secondary labels avoid temporary decoded ID/label vectors when only membership is needed. Each visited record still validates all label IDs, even after a match, and preserves primary/secondary deduplication. Multi-label scans keep the existing path.

An experiment that propagated required labels between MATCH patterns before plan construction was withdrawn after a measured planning regression (PR #1410); the anchored-cycle policy before it is in effect.

Direct EXISTS for a bound typed edge

An EXISTS over a single fixed typed edge between two uniquely bound vertices uses a direct index seek. It preserves endpoint/label checks, direction and errors from performed reads; unsupported shapes use ordinary execution. The seek may change which unrelated records are visited. Key construction reuses the target primary label just read and validated in the same transaction; it adds no cache and assumes nothing from a label carried in an input row. No setting is added. In the LSQB comparison of this change together with the planner rollback, Q3 recovered while Q8 and Q9 kept their 8 s timeouts; no isolated speedup is claimed. See the LSQB measurement ledger.

Completed-prefix filter pruning

For a pure fixed path with unique bindings, a leading AND condition may depend only on a prefix of the chosen endpoint traversal. After the first complete row has passed the normal path consistency checks, a false leading condition can skip the remaining suffixes of that same prefix. Duplicate prefix visits and physical-edge uniqueness retain the ordinary iterator semantics. UNKNOWN still evaluates the right condition; performed-read and evaluation errors propagate. An empty suffix never causes an earlier predicate evaluation. Pruned unread suffixes need not expose errors that ordinary expansion would have encountered.

Optional or variable paths, repeated bindings, seek hints, inherited join guards, and unproven effectful consumers retain ordinary execution. A middle entry or a condition depending on the final leaf retains the ordinary iteration on the same constructed source. No query name, graph schema, new cache or forced entry order selects this optimization.

Shared mandatory labels and branch access

After selecting ordinary join order and Hash/Apply strategy, the planner can propagate an unambiguous label constraint between occurrences of the same joined vertex within one mandatory MATCH scope. It then refreshes leaf scan strategies before eligible partial COUNT grouping. The rule preserves the selected join tree and does not cross optional matches, dependent predicates, scope rebinding or writes. It has no separate configuration switch.

Label population is only an entry estimate; fewer starting vertices can still produce more traversal work, so this rule does not guarantee a faster plan. Separate MATCH clauses and unsupported scopes retain their existing access planning.

Lazy compound IN membership

A sole immutable parameter or literal IN operand may use a lazily constructed, operator-local membership set when execution first reaches that predicate. AND, OR, XOR and NOT retain the ordinary recursive evaluator: skipped branches build nothing, and lhs/null and sticky-error handling precede membership lookup. Only stable plan predicates participate. Safe scalar candidates reuse the existing numeric-equivalence guard; collection needles or unsupported candidate values retain linear three-valued membership. Row-dependent RHS evaluation is unchanged.

Hash tables and retained keys use the existing query memory admission APIs. Total admitted candidate entries are bounded per operator; admission failure is propagated without replay, and context destruction releases retained owners. This introduces no shared result cache or new configuration flag. No isolated speedup is claimed.

Factor consumers

With PATINADB_FACTORIZE on (the default since 2026-10-17, #1442; off restores the ordinary operators), three more operators can work on a pattern that splits at an interior pivot into two independent branches (RFC 0042 B3–B5, #1442). They read each branch once per pivot instead of building every combination:

  • Top-k within a factor. ORDER BY … [SKIP s] LIMIT k, when each sort key belongs to the pivot or to one branch.
    • Each branch is sorted once per pivot, and only combinations that can still enter the page are expanded.
    • The edge-sorted and ordered-frontier paths keep priority.
    • If the page contains tied keys on different rows, the ordinary bounded top-k runs instead, so tie order is unchanged.
  • DISTINCT over one factor. RETURN DISTINCT under ORDER BY (with or without LIMIT), and WITH DISTINCT … feeding an ungrouped count, when the projection reads the pivot and at most one branch. The other branch only has to exist, so it stops at its first matching row.
  • Counts with a cross-branch comparison. Ungrouped count(*) with one a <> b, NOT a = b or a.p <> b.q (or =) between the branches is counted per pivot by inclusion–exclusion.

A branch with a single element at a pivot is folded into the pivot (flatten). When the factors stay degenerate, for example a single hop from an anchored node, the whole operator runs the ordinary plan instead. That choice is made from the operator’s own input and never changes results (ADR 0019).

PROFILE lists the executed choices under factor.topk, agg.factorized, factor.exists and factor.flatten. EXPLAIN is unchanged.

They have no cost admission: every proven shape engages, with or without the cost model. See limitations.

Factor-local grouping

PATINADB_FACTORIZE (on by default since 2026-10-17, #1442) enables a grouped aggregate for proven independent fixed branches. For two branches around a pivot vertex it reads each branch’s group keys once per pivot and combines the key multisets directly into the group table; the existing ORDER BY and LIMIT still apply afterward. PROFILE records agg.factorized when this path executes. It requires an admitted read memory scope. Unsupported patterns or value domains use the reference aggregate, and factor memory refusal falls back before output.

Branches may share a relationship type when they reach the pivot through it in the same way, for example two comments of one ticket:

MATCH (u1:User)-[:AUTHORED]->(:Comment)<-[:HAS_COMMENT]-(t:Ticket)
      -[:HAS_COMMENT]->(:Comment)<-[:AUTHORED]-(u2:User)
WHERE u1.name < u2.name
RETURN u1.name, u2.name, count(DISTINCT t)

Relationship uniqueness then requires two different comments. The engine checks this per ticket at run time, also when a comment has several authors. Floating-point aggregation and SUM with overflowing positive/negative subtotals are not currently admitted.

With PATINADB_COST_MODEL set to entry or join (the default join), the grouped path engages only when the degree catalog predicts that it is cheaper than the flat aggregate; without a catalog the flat aggregate runs. The catalog comes from the background sample the cost model requests on a miss, or from CALL patinadb.stats.degrees(). With PATINADB_COST_MODEL=off every proven shape engages. The weights are fitted on one benchmark host, so treat them as indicative; see RFC 0042 B1/B2 (#1442).

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

Index readiness

Since 0.16.0, CALL db.awaitIndexes() and CALL db.awaitIndexes(300) are supported. The optional timeout is a non-negative integer number of seconds, defaulting to 300. 0 checks readiness once without waiting. Negative values, null, non-integers and extra arguments are rejected.

The procedure completes without output columns, not with a boolean true; there is no YIELD ready. It preserves incoming rows when followed by another query clause. Ordinary index DDL completes synchronously, and subsequent writes maintain indexes synchronously, so a completed build normally needs no further wait. The procedure checks the local persistent deferred-index-build marker; while it is set, it polls until cleared or the timeout expires. Metadata read errors, query deadlines and shutdown cancellation propagate rather than being reported as success. It does not rebuild indexes itself.

This is a local readiness check for the current synchronous build lifecycle, not a validation scan of every index, a barrier for a concurrently starting DDL, or a readiness guarantee for a raw embedded store being modified without the normal query/write coordination. Future asynchronous index builders must join this readiness contract before being exposed to queries.

It also does not wait for Raft replication or history backfill: use bookmarks for causal reads on another replica. db.awaitIndex(name) is not implemented.

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.

ProcedureYieldsDescription
patinadb.ontology([options])viewThe database’s ontology as one JSON-LD document (concepts, label combinations, relationship shapes, scoped property statistics, declared constraints and indexes). Off unless PATINADB_ONTOLOGY_DISCOVERY is enabled; standalone, current-state and unrestricted-reader only. See Database Ontology View.

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.

What “APOC does not exist here” looks like. patinaDB ships no APOC library. Calling one as a procedure — CALL apoc.help() — fails cleanly with an unknown procedure error. But using an APOC function inside an expression (RETURN apoc.text.join(...), WHERE apoc.coll.contains(...)) currently surfaces a raw parser dump listing the tokens the grammar expected at that point (expected EOI, AND, OR, XOR, …) instead of a dedicated “no such function” error. Both mean the same thing: the function is not available. Check the procedure/function tables in this chapter and rewrite the query against what patinaDB actually ships.

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.

Degree statistics (RFC 0042 A1)

CALL patinadb.stats.degrees(edgeBudget?) explicitly refreshes the local degree catalog. It yields edge_type, direction (out/in), anchor_label, other_label, edges, anchors (nonzero degree), population (including zero-degree vertices), p50, p99, max, heavy_hitters, provenance, sample_edges and sample_anchors. Both endpoint labels include secondary labels. The cost model (PATINADB_COST_MODEL, default join since 2026-10-17, #1442) uses fresh degree groups and requests a bounded background sample on a miss, so this call is not required; use it for an exact catalog or a warm-up after bulk loads. With PATINADB_COST_MODEL=off the planner does not consume these statistics. See configuration.

The positive integer budget counts directional adjacency entries across both trees. It defaults to PATINADB_FANOUT_RECOMP_MAX_EDGES. Above the budget the scan samples 256 uniformly spaced UUID intervals, assuming uniform UUIDs; structured/nonuniform IDs can bias estimates. provenance is exact or sampled. Counts and histogram buckets are extrapolated for samples; max is the observed maximum, and each reported heavy hitter has its exact observed degree. The SpaceSaving list is a candidate summary, not a guarantee of the global top 16. Percentiles are log2-bucket upper bounds, including zero-degree anchors. Groups absent from the sample are absent from the output, not proven empty.

An exhausted edge/group/memory budget or deadline fails the refresh without publishing a partial catalog. The explicit call can scan vertex membership for population counts. Its cache occupies a separate bounded, governor-visible stats.degree region; cache-disabled calls still return their freshly computed rows. Planner paths never run this explicit refresh. With PATINADB_COST_MODEL set to entry or join (the default), a planner miss can request the separate bounded background sample described in configuration.

patinadb.stats(label?) now includes secondary-label holders in its summary and property rows. Existing primary-label output columns remain unchanged.

Database Ontology View

The ontology view describes what is in one database: its concepts (node labels), the exact label combinations nodes carry, the directed relationship shapes between those combinations, the properties observed on each label and relationship type with their value types, and the constraints and indexes that are actually declared. It is a read-only projection of the current committed state (RFC 0039).

The view is descriptive. An observation never becomes a rule:

  • 100 % observed coverage of a property is shown as present on all observed members, never as required. A later write without that property is still valid unless an actual constraint says otherwise.
  • Labels that occur together (:Supplier:Preferred) are reported as one label combination. No subclass relation is inferred.
  • Observed value types are not a type declaration, and observed endpoints are not a domain/range declaration.
  • Only real catalog definitions appear as declared rules. When observed data contradicts a declaration (for example a missing value under an existence constraint), both are shown together with the discrepancy; the data is not changed.

Enabling discovery

Exact discovery reads the whole graph, so it is off by default on a server and in the console’s file mode:

PATINADB_ONTOLOGY_DISCOVERY=on patinadb-raft …

Without it, the procedure fails with patinadb.ontology is disabled on this server. The Rust embedded API Dataset::ontology_view is not affected by this switch.

Querying it

CALL patinadb.ontology() YIELD view RETURN view
CALL patinadb.ontology({mode: 'exact', maxRecords: 10000, maxResultBytes: 4194304})
YIELD view RETURN view

The procedure yields one row whose view column is a structured map (a JSON-LD document), not JSON text. It works over embedded Dataset::query, REST /cypher and Bolt, and every transport returns the same document. The database comes from normal routing (USE <db> or the session database).

Options (all optional): mode must be 'exact'; maxRecords and maxResultBytes may only lower the server limits below. Any other key, or a larger value than the server limit, is an argument error raised before any data is read.

The call must be a standalone read of the current state. These are refused with an explicit error instead of returning something weaker:

  • a call per input row (UNWIND … CALL patinadb.ontology()), inside a subquery, or in a writing statement;
  • inside an explicit transaction;
  • any AS OF call, including AS OF the current HEAD (historical ontology views are a later increment).

Authorization

The view covers the whole database, so it requires unrestricted read access to it (the ordinary Reader role is enough). A principal holding any label-, relationship-type- or relationship-property-scoped grant on that database is refused (Neo.ClientError.Security.Forbidden), even if the relationship strict modes are off. The result is never filtered to an empty or partial ontology, and a refusal carries no label names or counts.

Limits

SettingDefaultMeaning
PATINADB_ONTOLOGY_MAX_RECORDS50 000Records in one view (concepts, label combinations, relationship types and shapes, property terms and observations, declarations).
PATINADB_ONTOLOGY_MAX_RESULT_BYTES32 MiBEncoded size (compact JSON) of one view.
PATINADB_ONTOLOGY_MAX_WORKING_BYTES256 MiBAccounted working state while collecting.

All three are read once at startup. Exceeding one fails the call with ResourceExhausted; no partial view is returned. Accounting is an estimate, not a hard process-memory limit. Collection time grows with the number of nodes and relationships (every node, property and relationship is read); the size of the result grows with the number of distinct label combinations and property names. Measured examples are in the RFC (tag RFC0039).

Deadlines (--query-timeout) and cancellation stop collection; read faults fail the call.

The JSON-LD document

The document carries an inline @context (no network access needed), an absolute @id naming this view, and an @graph. Its first record is the patina:OntologyView with:

FieldMeaning
format_version1.
databaseRegistry name, incarnation (or identity_status: unassigned).
snapshotscope: head, the HEAD Engram read in the same snapshot, read_state: local_applied_state (a follower reports its own applied state).
coveragemode: exact, observations: complete, scope: whole_database, per-collection status; values_included: false, uniqueness_validated: false.
catalogstatus: available_at_snapshot, a fingerprint of the declarations, the catalog families read, and the excluded legacy family.
concepts, node_shapes, relationship_types, relationship_shapes, properties, constraints, indexes, discrepanciesLinks to the records in the same graph.

Concepts are rdfs:Class, relationship types and scoped properties are rdf:Property; everything else uses the versioned Patina vocabulary (urn:patinadb:ontology:v1:, never dereferenced). Property observations carry population_count, present_count, missing_count and observed_types (one count per type: Integer, Float, String, Uuid, Boolean, LegacyTimestamp, Date, LocalTime, Time, LocalDateTime, DateTime, Duration, Point, Polygon, List, Map, Path). Node property statistics cover every node carrying the label (primary or secondary); relationship property statistics are type-wide. A stored null counts as missing. Counts above 2^53−1 are written as exact xsd:integer literals. Record IRIs are stable for the same database incarnation and names; they are not node or relationship ids. No property values are included.

Control Panel

The console’s Ontology screen (#/ontology, in the Query section, all connection modes) loads the view only when you press Load ontology — it never scans on navigation. The screen shows the database, snapshot, mode and catalog status; a searchable list of concepts and relationship types; a type-level graph whose vertices are label combinations and whose arrows are relationship shapes with counts; and a detail panel separating observed properties from declared rules. Filtering only changes what is displayed; Download JSON-LD always saves the complete received document. Permission, disabled, unsupported-server and limit failures are shown as such, never as an empty ontology; a failed refresh keeps the previous result, labelled as previous. Each console instance serves one database, so switching databases means opening that database’s console; responses for a previous session or database are discarded.

Not included yet

Historical views and comparison, sampled discovery, SHACL export, MCP access and scoped (filtered) ontologies are not available. See Limitations.

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.

Runtime security updates

The bundled runtime requires Wasmtime 48.0.7 or newer in the 48.x series. This includes the fixes for RUSTSEC-2026-0378 and RUSTSEC-2026-0379. These dependency updates preserve the procedure ABI and sandbox limits; they do not require re-registering stored functions.

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

Pure computation is deterministic for the same module and arguments. Graph-reading procedures additionally depend on the pinned committed graph state; identical arguments on replicas at different applied positions need not return the same value. Runtime safety limits can also reject a call rather than return a value. patinaDB enforces the following sandbox boundaries:

  • 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.
  • The table-element count is capped too (65,536 entries). A guest’s call_indirect dispatch table is separate memory, so the linear-memory cap alone never governed it; without a ceiling a module could declare a billions-of-entries table and allocate far past the 16 MiB cap. Like memory, this is a fixed limit applied identically on every node, so a module either instantiates everywhere or nowhere.
  • What the guest hands back is validated before the host acts on it. The returned result blob’s pointer and length are checked against the guest’s own linear memory before the host allocates a buffer for it, and the blob’s nesting is depth-bounded (PATINADB_WASM_MAX_DECODE_DEPTH, default 256) because the host-side decoder runs after the guest call has returned — where fuel and the wall-clock deadline no longer apply. Either violation is a clean procedure error.
  • 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

Calling from an application backend

Requires 0.21.0: correlated procedure calls after WITH preserve the preceding row bindings. The published 0.20.0 can lose those bindings in this shape. The application example below requires the seeded-procedure fix; it is not evidence that the old release supports it.

Register the module through a separate administrator connection. The backend’s technical user does not need administrator rights to call a registered read-only procedure. Do not accept a module path, arbitrary procedure name or Cypher text from an application request.

For a paginated endpoint, select and authorize the nodes before invoking the guest, then explicitly order the returned rows. For example, with $projects derived from the authenticated user’s backend ACL rather than client input:

MATCH (ticket:Ticket)
WHERE ticket.project IN $projects
WITH ticket ORDER BY ticket.created_at DESC, ticket.issue_key ASC
SKIP $skip LIMIT $limit
CALL wasm.readSummary(ticket) YIELD result
RETURN ticket.issue_key AS key, result AS summary
ORDER BY ticket.created_at DESC, ticket.issue_key ASC

The operator supplies readSummary; it is not a built-in function. Its guest can call node_get_property with the property name summary and forward the packed result. Graph-reading determinism requires the same graph snapshot as well as the same module and arguments. A later call after a committed update can return a different value. Use the normal causal bookmark contract when a reader must observe a preceding write; WASM does not provide a separate replication barrier.

The guest’s graph access does not implement your application’s custom ACLs. Only install trusted modules whose reads respect the backend’s authorization boundary. A guest that traverses from an authorized node to arbitrary other nodes needs a separate authorization design; filtering its initial argument alone does not establish that all traversed data is authorized.

During rollout, keep guest traffic in a separately measured workload lane. Stopping that lane after errors may preserve other customer traffic, but is not a successful WASM qualification. Retain errors and distinguish unexpected query failures from intentionally tested sandbox traps.

Engine limits

  • 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 FULLTEXT INDEX <name> and DROP INDEX <name> drop a full-text index. An unknown name is an error; add IF EXISTS for an explicit no-op when absent. Malformed trailing text is rejected without deleting the index. Registered ordinary indexes take precedence for the shorthand DROP INDEX <name>; DROP INDEX ON :Label(prop) remains the ordinary-index form.

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

Measured Jira-summary workload (0.17.0)

On 334,770 real Apache Jira summaries, standard analyzer, one indexed summary property, local Ryzen 7 3800X/NVMe, the 2026-09-10 measurement under #1261 found:

  • Seventeen tested term/boolean/phrase/prefix/fuzzy/project-filtered forms had warm Bolt Top-10/20 p90 between 2.173 and 63.868 ms (20 samples per cell).
  • A short 2 reads/s plus 0.1 summary updates/s run had read p90 66.816 ms and p99 122.826 ms; updates, batch changes, graceful reopen and deletion passed search-visibility checks. Exact hit sets and single-term BM25 scores were independently checked.
  • Server high-water RSS reached 378.957 MiB through the batch phase; the standalone build peaked at 464.113 MiB. These exclude the Python oracle.
  • Initial construction took 7.950 seconds, versus 1727.563 seconds in the retained 0.16.0 baseline: 217.31 times faster on this fixture (#1261). Allocated database-directory size after building fell from 1375772672 to 477433856 bytes; this is the whole directory, not posting-only storage.

These are summary-only, single-node measurements, not 10M-ticket, multi-region, long-soak, ticket-body/comment or other-analyzer guarantees. Full methodology and raw artifact locations are recorded in the repository’s docs/fulltext-benchmark-1261.md (#1261).

Construction and resource use

Initial construction and reconstruction stage postings into sorted runs and write each term’s final list once. The existing format and synchronous DDL boundary remain unchanged; updates after construction still use per-document maintenance. Existing indexes remain compatible without rebuilding.

The builder uses the sort-run byte setting and private temporary files under TMPDIR. Budget additional memory for merge readers, the largest posting list, serialization and database caches; the run buffer cap is not a total-RAM ceiling. Scratch files are removed on ordinary completion/error/cancellation, but a hard process kill can leave temporary files.

Feature boundaries

  • 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

Since 0.16.0, PATINADB_VECTOR_BUILD_THREADS controls a shared, bounded training pool (1–8 threads, 1 for serial). Initialization caches nearest scores and parallel assignment preserves ordered accumulation. See 0.16.0 Release Notes for defaults and validation scope. This is not a setting for concurrent redb writers or a total-RAM limit.

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.

Development behavior (not yet released): Training preparation cooperates with the configured server query timeout, including waiting for database write admission. A timeout during preparation returns an error before the new definition is proposed to Raft; it does not roll back a definition that has already been committed. Stored-data read errors fail the build instead of silently omitting unreadable vertices.

A definition becomes visible only after its posting lists have been built. Rebuilding an existing name first withdraws that definition. If backfill fails, the index remains unavailable until the underlying error is resolved and the build is retried; the previous index is not restored automatically. Use IF NOT EXISTS when an existing index should be left untouched.

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 so squash cannot remove it. On a Raft server, the full snapshot is built asynchronously after the tag commits. Until then, reads reconstruct the tagged state through the existing history path; snapshot completion is an acceleration, not a prerequisite for correct reads. The embedded Dataset::create_tag API still builds the snapshot synchronously.

CREATE TAG v1                                  -- tag the current HEAD
CREATE TAG release AS OF '<engram-id>'      -- tag a specific engram
SHOW TAGS                                       -- list name → engram
SHOW JOBS                                       -- admin: local snapshot job status
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 and pins the same engram, then builds its snapshot independently. History-incomplete nodes retain the existing history-availability restrictions. See maintenance jobs for status, retries and the distinction between tag commit and snapshot readiness.

For a historical tag based on an existing snapshot, the background exporter retains one private primary-data base and replays each tag’s changes in a separate overlay. It omits query indexes from that base because snapshot records contain primary data and index definitions. Ordinary historical queries still use fully indexed reconstruction; the export base is never a query store. A cached ancestor may also serve tags beyond a newer snapshot when its complete replay chain can be verified. Reuse checks the history floor, unchanged compound index definitions, and snapshot-only transitions without a replayable delta. It declines optional reuse beyond 4096 engrams or 64 MiB of decoded serialized delta work, and retains the normal nearest-snapshot path. These are work estimates, not total-memory bounds. Each delta is still loaded and applied through the ordinary checked history path. Both temporary stores use the disposable-cache cap described under configuration. This avoids building those unused indexes, but still reads and writes snapshot data and can compete with live I/O. It is not a latency guarantee or a cap on total process memory. Snapshot publication keeps its existing durable completion boundary.

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. Forking is subject to the same user-database limit as CREATE DATABASE; reaching that limit rejects the fork before replication.

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.

Large restores are chunked. The restore delta (the creates needed to reach the target state, then the deletes for everything not in it) is proposed in batches of PATINADB_RESTORE_OPS_CHUNK operations — default 5000, and the same knob governs POST /mgmt/restore. A restore smaller than one chunk is a single atomic entry, exactly as before. A larger one commits as several entries, which has one consequence worth knowing:

A chunked restore is not atomic. Intermediate, partially-restored states are observable between chunks, and a failure part-way through leaves the graph in one of them. Restore is an offline administrative operation — do not run it against a database serving live traffic and expect a clean cut-over. All creates are ordered before all deletes, so the intermediate states are supersets of the target, never missing data the target has.

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.

Maintenance jobs

For large portable backups use the explicit streamed archive and offline admin restore. Legacy JSON remains available; the new binary format avoids exporting all reconstructible history snapshot caches.

Long-running work and squash

Background execution does not remove the need for resource limits or query deadlines. Interactive queries remain synchronous and bounded; large maintenance needs explicit admission, status and completion semantics. Losing a connection is not proof that an admitted job was cancelled.

Prepared squash jobs are experimental and disabled by default. The explicit REST interface described below separates Raft admission, replica preparation, publication and completion. It does not change CALL patinadb.squash: that command remains synchronous, including its existing apply work and possible request timeout. Planning shares the maintenance artifact lane and streams the canonical identity, but this is not a production or performance qualification. A database with at most 4096 live vertices and edges plans on a separate small-database lane instead, so it does not wait behind a long tag, backup or squash job for a larger database. At most one such small squash runs beside one heavy job per node; heavy jobs remain serialized. The bound counts live records, not file size: a database file keeps its high-water allocation after history churn. The bound is a heuristic, not a measured memory limit.

The #1261 1M maintenance run6 still failed at the synchronous squash HTTP timeout. Post-squash PITR export/restore was not reached. Successful historical-read tests or component preparation tests do not supersede that failure; a new complete maintenance measurement is required. A subsequent #1261 component run on the 1M-ticket copy passed (222.090 s preparation, 5.145 ms local publication, 2.096 GiB maximum RSS; fresh process with warmed OS cache). Raw evidence: async-squash-1261-20260913-component-run2/summary.json and benchmark.log; repository report: docs/async-squash-1261-2026-09-13.md. A small real cluster also passes history-inclusive backup and pristine PITR restore after squash. Neither result substitutes for the complete mixed-load 1M maintenance gate. The repository qualification driver selects this experimental path with history_qualify.py --maintenance-only --async-squash; it requires committed completion before PITR export and checks pinned history on a pristine restore.

Experimental prepared squash

Keep PATINADB_EXPERIMENTAL_SQUASH_JOBS disabled until the deployment’s qualification gates pass. For an explicit experiment, enable it consistently on every participating node, including learners. All members must support protocol 16; do not downgrade a cluster containing durable squash-job records.

The endpoints require global Admin and access to the target database:

POST /mgmt/squashes
GET  /mgmt/squashes/<job_id>
POST /mgmt/squashes/<job_id>/cancel

Submit JSON on the leader, for example:

{"database":"default","keep_recent":10,"request_id":"baf2012c-205f-41b7-8bf5-612c10518826"}

request_id is an optional client-generated UUID. Reusing it with identical database/options returns the existing replicated record while it remains; different options conflict. Omitting it creates a new request. Optional before is a Unix-seconds cutoff. keep_recent must be at least one. A job handles one selected prefix of at most 4096 Engrams: completion does not guarantee that the whole database has reached the requested retention target.

HTTP 202 acknowledges durable admission, not completion. The record identifies the fixed prefix; ordinary graph appends do not move that job’s cutoff. One job per database can be active, with limits of 32 active jobs and 256 retained records cluster-wide. These are admission limits, not throughput measurements. Terminal-record deletion/expiry is not exposed yet; the retained-record ceiling can refuse additional submissions. Dropping a database retains terminal records: unfinished work becomes Superseded, successful work becomes Retired. This preserves request deduplication and the protocol-16 minimum across drop/recreate; dropping databases is not a way to reset that compatibility requirement or cap.

Replicated stateMeaning
QueuedAdmitted; replicas may be preparing or waiting
Committed { identity, members }Publication decision is durable; cancellation is too late
Succeeded { index, identity }Finalization committed after matching publication receipts from all current members
Retired { index }A newer squash or database deletion retires that completed identity; its completion index remains
Cancelled / SupersededQueued work cancelled/invalidated, or unfinished work superseded by database deletion

These are logical state names; the detail endpoint returns the serialized record. Preparation and publication receipts are local to each node. Both readiness barriers include every current member, including non-voting learners, not just a voter quorum. An unavailable learner can therefore delay completion. Membership changes require receipts from the new current membership.

Normal HEAD reads and graph writes can proceed during preparation; CPU, disk, MVCC retention and the artifact lane remain shared. During committed publication, affected history queries and retention metadata are fenced rather than exposing different old/new history boundaries. History backup is refused until publication is safe; retry after completion. Busy publication retries; neither preparation nor publication has a qualified latency guarantee.

Cancel explicitly while the record is Queued, including while a worker is preparing. A racing commit can win: cancellation then returns a conflict and recovery must finish the decision. Disconnecting a client never cancels a job. Do not use feature-flag changes, node shutdown or manual staging-file deletion as cancellation mechanisms.

Job decisions survive restart and ride Raft snapshots. A recovering node must verify its published boundary or recover the exact decided artifact; absence of a local staging manifest is not proof of cancellation. A node without the needed history remains fenced until recovery. Successful logical publication does not itself prove reclaimed disk, an off-host backup, or a successful PITR restore. The complete crash, learner, mixed-load and post-squash restore gates remain part of experimental qualification.

Disabling new admission does not stop recovery/finalization of committed jobs. Existing status/cancellation endpoints remain available; query job details on the leader for a fresh status barrier. Durable squash records automatically keep history backfill available for recovery, even without its experimental environment setting after restart. Cleanup may run after publication, but its progress is not a replicated job-completion guarantee and staging-artifact expiry is not provided.

Tag snapshot jobs

The Raft server runs tag-snapshot preparation outside serialized Raft apply. CREATE TAG returns after its name, exact Engram and retention pin are durably committed; it does not wait for the full snapshot. The tag is immediately usable through existing historical reconstruction where that history is available.

Waiting tag jobs retain their durable history reference, but do not hold a native database read version (#1485). A worker acquires its immutable view when preparation is admitted. If the tag is no longer at that view’s HEAD, the worker uses the retained historical reconstruction path. This can require more replay than capturing at submission; no preparation-latency guarantee is made. An active snapshot or query can still retain old disk pages while writes continue. Releasing a native reader permits reuse of eligible pages; it does not promise an immediate decrease in the database file’s allocated disk size. The durable history pin remains until the tag is removed. No new setting is required. A causal bookmark covers the tag commit, not background snapshot completion.

Historical fallback can reuse one private primary-data snapshot base between jobs, without constructing query indexes on that base. Each target replays its own deltas in an isolated overlay. This export-only base is separate from the fully indexed stores used for historical queries and is invalidated with history. Reuse can cross newer snapshot boundaries only with a checked ancestry chain, unchanged snapshot definitions and replayable deltas. Missing optional history, a floor boundary, snapshot-only transitions or the bounded replay-work ceiling retain the nearest-snapshot rebuild. No tag is declared ready before publication. Snapshot I/O still competes with live traffic; there is no background latency or total-memory guarantee.

Inspect the queue

As an administrator, use Bolt or the existing Cypher HTTP endpoint:

SHOW JOBS;
USE default SHOW JOBS;

Tag rows are local to the receiving node and selected database; backup rows are node-local full-registry jobs (database = '*') included in every database’s listing. The result is not a cluster-wide view; a remote learner may be at a different stage. HTTP returns the rows under jobs. Bolt returns these columns:

ColumnMeaning
job_idtag_snapshot:<database>:<engram> for live tag targets; UUID for backup and squash requests
node_idNode ID as a string
databaseSelected database for tags/squash; * for full-registry backups
kindtag_snapshot, backup, backup_history, or experimental squash
engramTarget UUID for tags; selected cutoff for squash; empty for backups
tagsCurrent tag names sharing this snapshot; empty for backups/squash
stateTag: queued, running, retry_wait, succeeded; backup and squash states described separately
attemptsTag/squash local diagnostics may reset after restart/cache eviction; backup count is persistent
last_errorSanitized failure indication, or null; details are in the node log

For tag rows, succeeded means this node has published the target snapshot. It does not mean all replicas have done so, nor that an off-host backup exists. There is no percentage estimate. SHOW JOBS currently has no WHERE, YIELD, ORDER BY, manual retry, cancellation or priority syntax. It requires global Admin.

Squash rows show the receiving node’s applied replicated state for the selected database, not a linearizable cluster-wide status poll. Their lowercase states are queued, committed, succeeded, cancelled and superseded; retired records also appear as succeeded here. Use the detail endpoint to distinguish retirement. Squash attempts and sanitized errors describe only the receiving node; waiting members and percentage estimates are not exposed. Cancel through the explicit REST endpoint, not SHOW JOBS syntax.

Recovery and resource behavior

Live tags are the persistent work list. Multiple tags pointing to one Engram share one job. Pending work is rediscovered after restart or history backfill; preparation can repeat, but publication is checked and idempotent. Failures retry with bounded backoff. Error/attempt history is a bounded in-memory diagnostic, not an audit log. Rows disappear when their last tag reference disappears.

One tag job executes per node at a time and shares the existing process-wide snapshot-build admission limit. A waiting job does not retain a read snapshot. An active job retains its immutable view and history dependencies. Normal graph writes can continue; destructive history operations may wait for that lease. Disk, CPU and retained MVCC pages are still shared resources, not isolated quotas. Historical targets may require on-disk reconstruction before snapshot streaming.

DROP TAG removes demand; moving a tag changes its target. An already-running serializer may finish its pass, but obsolete work is discarded before publication. Database replacement and snapshot installation also invalidate old prepared work. Do not explicitly unpin live tag targets whose history you need: the existing administrative unpin override still removes their retention protection. Dropping the final tag retains the usual unpin behavior.

This first increment covers server-side CREATE TAG. Embedded synchronous tagging and tagging inside existing bulk/upsert APIs retain their previous behavior. User queries and the legacy synchronous squash command have not been moved into the tag queue; experimental squash jobs use their own replicated lifecycle. The existing synchronous backup endpoint keeps its completion contract. During a rolling upgrade an older node may still build tag snapshots synchronously.

Asynchronous backups

All backup-job endpoints require global Admin and use the same authentication as /mgmt/snapshot. Archives contain every database and the user directory, including password hashes: protect downloads and server storage accordingly.

Submit on the leader, using HTTP POST:

POST /mgmt/backups?history=false&request_id=<client-generated-UUID>
POST /mgmt/backups?history=true&request_id=<client-generated-UUID>

The response is HTTP 202 with a persistent job_id. Repeating the same request ID on the same node, while its record remains, returns the existing job; changing its history option returns 409. Omitting the ID creates a new job each time. IDs are not cluster-wide retry tokens: after a lost response, inspect the original node rather than blindly resubmitting on another leader. PITR/history still requires its license entitlement and complete local history. These checks are repeated when execution begins.

GET    /mgmt/backups/<job_id>
GET    /mgmt/backups/<job_id>/file
DELETE /mgmt/backups/<job_id>

SHOW JOBS exposes queued, running, ready, failed, and expired for backups. Backup rows have an empty engram and no tags: the archive’s heads map names the per-database capture points. The detail endpoint also provides the history option, byte length, creation/expiry timestamps (Unix seconds), attempts, and a sanitized error. Capture occurs when the worker executes, not at enqueue time; it takes a fresh leader barrier. Queued jobs hold no database read views.

ready means the complete archive has been flushed, synced and published locally, not that an external backup exists. Download the file, persist it outside the cluster, and verify it using a restore drill. Completed HTTP streaming updates the existing backup-success stamp; this still cannot prove client-side persistence. Interrupted downloads retain the archive and can be restarted from byte zero. There is no Range/resume support in this increment.

Jobs and files belong to their original node and are not Raft-replicated. On restart, queued jobs remain queued, ready archives remain downloadable, and interrupted running jobs become failed. They are not silently retried against a different data cut. Submit a new ID to retry a failed job. Leadership loss before capture can fail the job; no automatic cross-node failover is promised.

One backup/tag serialization job runs per node through shared admission. Up to four backup downloads can be active. A node keeps at most 16 job records; delete terminal jobs to free slots (active jobs cannot be deleted). Ready archives expire after 24 hours; queued jobs expire 24 hours after submission. Expired files are reclaimed by maintenance, while status records remain until deletion. These are configured limits, not performance measurements.

PATINADB_BACKUP_JOB_MAX_BYTES limits each asynchronous archive when explicitly set. The legacy JSON format defaults to 16 GiB; format=archive has no default total-byte cap and uses the offline admin restore tool described above. PATINADB_BACKUP_JOB_MIN_FREE_BYTES sets the filesystem free-space floor (default 1 GiB). Both accept positive integer byte counts. Serialization checks available space periodically and fails rather than publishing a partial archive; this is not an exclusive disk reservation against other processes. Budget MVCC retention, Raft snapshots, downloads and normal writes separately. The old synchronous JSON endpoint is not governed by these job-specific limits; explicitly choosing format=archive applies the disk reserve and any configured byte budget there too.

The JSON backup/restore format is unchanged. Missing referenced history snapshot bodies now fail export rather than silently produce an incomplete PITR archive (#1289). Backup capture also opens the referenced snapshot files under a short, nonblocking retention lease (#1291). The backup retains those handles through serialization: later pruning or atomic replacement cannot remove its captured bodies. The lease is released before serialization, rather than held across the export. Active retention can refuse capture; submit a new job after it finishes. Each captured snapshot consumes a file descriptor until the view is dropped; descriptor exhaustion or a missing body fails capture, never produces a partial successful archive. Unlinked bodies still consume disk while handles remain open. Avoid concurrent database/schema/RBAC changes during preparation: the existing consistent-cut guarantee covers graph, HEAD and history, not an atomic snapshot of every live schema/access-control definition. Ordinary graph updates are covered by the pinned views. Ready-file checks detect missing or size-mismatched files, not arbitrary same-length corruption; retain independent integrity checks and restore drills for the downloaded recovery copy.

The legacy synchronous endpoint also retains cleanup ownership during preparation and the handoff to its response stream (#1292). Cancelling the request does not leave an unowned completed archive behind once the blocking preparation finishes. This is distinct from async jobs: disconnecting a client does not cancel a durable job, and its ready artifact remains available until expiry or deletion.

Large backups and offline restore

The versioned portable archive is the streaming alternative to the legacy JSON backup. It carries the registry’s graph, schema and users; history mode also preserves its Engrams and tag targets. Reconstructible history snapshot caches are omitted, but indispensable history bases and import/restore-marker snapshots remain, including markers that have older history as a parent.

Archive version 2 also preserves single-property registrations, compound index definitions and names used by DROP INDEX name. Online archives capture these from the same pinned view as the graph. Restore rebuilds compound index entries before publishing the destination. Use the matching server/admin revision: older readers reject version 2 rather than silently dropping its metadata. The new reader still accepts version 1, but cannot reconstruct index declarations that those archives never recorded (#1327). Recreate missing declarations from your schema migrations, then take a new backup; do not assume a legacy archive is a complete schema backup.

Online backup job

Submit on the leader using your existing global Admin credentials:

POST /mgmt/backups?history=true&format=archive&request_id=<UUID>
GET  /mgmt/backups/<UUID>
GET  /mgmt/backups/<UUID>/file

HTTP 202 means admitted, not complete. Download only when the job is ready; save the binary response as backup.pdb. Reusing the UUID with another history mode or format conflicts. History backup requires the existing PITR entitlement. Job status, retention and deletion otherwise follow maintenance jobs.

When PATINADB_BACKUP_JOB_MAX_BYTES is unset, this archive format has no fixed total-size cap. Setting it imposes an explicit byte budget. The disk reserve PATINADB_BACKUP_JOB_MIN_FREE_BYTES still applies; running out of capacity is an error, never a successful partial backup. Legacy JSON behavior is unchanged.

Required admin binary

Build both tools from the same revision:

cargo build --release -p patinadb-raft --bins

For a stopped registry, an offline backup avoids network transfer entirely:

patinadb-admin backup --from /data/patina --output /backups/backup.pdb --history
patinadb-admin verify /backups/backup.pdb
patinadb-admin restore /backups/backup.pdb --into /data/patina-restored

The server image also includes patinadb-admin. With Docker, override the server entrypoint using --entrypoint /usr/local/bin/patinadb-admin, mount the archive and target parent directory, and grant the container user access to those paths. Stop the source server before an offline backup; never restore over its mounted registry.

The output archive and restore destination must not already exist. There is deliberately no --force: the tool must never overwrite a running or mistaken target. Restore needs an existing parent directory on Linux and enough space for staging, index rebuilds and its free-space reserve. It does not contact a server and is not limited by an HTTP/query timeout. Run it as the intended database OS user, or deliberately adjust ownership before starting that user’s server; archive files and staging are private.

Both backup and restore accept --min-free-bytes (default 1 GiB). Restore also accepts an optional --max-archive-bytes policy limit. These do not provision space; monitor allocated disk and leave additional headroom for rebuilding indexes. The full restore’s memory/index-build behavior still needs qualification at your scale. The chunk payload is bounded to 1 MiB and manifest to 64 MiB; there is no graph-sized JSON snapshot in the new archive path.

verify checks the manifest and complete chunk/checksum chain. It is not a substitute for a restore drill: restore also validates graph/history records and rebuilds indexes. Truncated, corrupt or budget-exceeding input never publishes its staging registry. A crash may leave a private .patinadb-restore-* sibling; do not start a server on an unfinished stage.

Archives record each source database’s incarnation and history generation when it has one. An offline restore stores that as the restored database’s restored_from lineage; the database itself gets a new incarnation from the leader once the fresh cluster runs protocol 17. Archives from older servers restore without lineage. An archive carrying lineage is rejected by older patinadb-admin binaries (unknown field) rather than restored partially.

After restore, start a fresh cluster using the new registry directory and your normal license/configuration. Old Raft membership, logs, causal bookmarks and deduplication receipts are not restored. Do not drop this directory under an existing cluster identity. Users/password hashes and grants do survive: protect the archive like the original database. Checksums are not encryption or proof that the archive came from a trusted party.

Restored cluster bootstrap (#1325, development correction): older servers can let a new learner reach the causal log index without receiving the restored graph. Use the corrected server for the restored registry’s first start and initialization, not only when adding replicas later. A registry already initialized by an older server without the new bootstrap marker must be restored into a fresh registry for this procedure. Upgrading an already inconsistent cluster is not an automatic repair.

Initialize the restored registry with one local voter first (--bootstrap or a single-member POST /mgmt/init). The corrected server rejects direct multi-voter initialization of restored state. Before admitting the first new learner it durably checkpoints that state and retires the incomplete log prefix. An interrupted checkpoint remains pending across restart. A retryable DatabaseUnavailable response means admission has not completed: retry the same membership intent, rather than clearing bookmarks or manually copying live database files. The checkpoint can outlive an individual request. Its required baseline frontier is pinned durably on the first attempt: later writes remain a replayable tail rather than moving the retry target. A checkpoint from an obsolete causal domain is rebuilt before admission can complete. The server also waits for Raft’s snapshot-publication acknowledgement before requesting the corresponding log purge; a file appearing on disk is not enough.

Add the other nodes as learners, verify their graph and indexes, then promote the two additional local voters together. Keep regional replicas non-voting. Switch backend endpoints and trust configuration only after verifying the new cluster and imported grants. Old causal bookmarks are invalid in the fresh cluster. Do not silently discard them and retry a request; explicitly start a new recovery session after reconciling the acknowledged backup cut. Write-token receipts are also not restored: replaying an old write token can execute its mutation again. Reconcile uncertain pre-disaster writes before resubmission; tokens generated in the new cluster retain normal deduplication.

The development correction passed same-host SATA recovery at 64 and 4,096 tickets into three voters plus one regional learner, with three Rust/neo4rs backends repointed and fresh causal writes verified (#1261/#1325). Four real process-abort/restart boundaries passed at 64 tickets. Historical-cut checks cover the initially restored node, not immediate history availability on every replica. These are not independent-host, power-loss, WAN or 1M multi-node recovery results; keep deployment-specific restore drills.

Offline backup captures a stopped node’s durable local state; it does not prove that this node was current with its former leader. Recover incomplete installs and finish required history/publication recovery before taking a PITR backup.

Compatibility and qualification

format=archive is explicit. Existing JSON clients and POST /mgmt/restore remain unchanged; send the binary archive to patinadb-admin, not the JSON restore endpoint. The older embedded patinadb backup/restore format is also distinct. Unsupported magic/version is rejected rather than guessed.

Legacy JSON POST /mgmt/restore?force=true can create _restore_pre_* tags for the pre-restore state of existing databases. Treat those names as opaque. The #1323 development fix binds new names to their exact Engram instead of seconds, preventing repeated force restores from moving an earlier generated marker. Existing tags are not rewritten; this does not change archive restore or make force-merge restore a pristine disaster-recovery operation.

The previous #1261 JSON PITR run exceeded its configured 32-GiB budget and did not reach restore. That failed run remains failed. A new archive/admin drill on 2026-09-23 restored a one-million-ticket PITR archive under the current workflow: 3,896,529,031 transfer bytes, 259.682 seconds offline restore and 1,483,657,216 bytes sampled restore peak RSS (#1261). Boot-to-ready afterward was 0.202 seconds; do not confuse that with full recovery time. Mixed read p90s were 78–169 ms; several exceeded three times idle despite passing the existing 300-ms floor. No latency threshold was relaxed for this run.

A separate two-hour local five-node run recovered four HEAD/PITR archives into fresh isolated registries and checked graph, indexes, grants, constraints and subsequent writes (#1261). The source database directories were inaccessible during those restores. The large run selected PITR restore only; a HEAD export served as a contention control. These are single-host results, not off-host DR or a guarantee for your history size. See the qualification report for exact binary identity, failures, resource reserves and remaining gates.

Historical diff jobs

Use synchronous CALL patinadb.diffRange(from, to) for an interactive comparison. It still returns rows and respects query timeouts. For a larger comparison, an administrator can explicitly submit a node-local background job instead of holding a request open. This does not make arbitrary Cypher asynchronous.

Submit on the private management endpoint, using your operator authentication:

POST /mgmt/diffs
Content-Type: application/json

{"database":"default","from":"<old-engram-uuid>","to":"<new-engram-uuid>","request_id":"<client-generated-uuid>"}

Use JSON null for from to compare against an empty graph. Resolve tag names with SHOW TAGS first; endpoints are fixed Engram UUIDs, not moving names. HTTP 202 returns job_id and state. Retrying the same request ID and options on the same node returns the existing record; different options conflict.

OperationInterface
Inspect local jobs for a databaseUSE default SHOW JOBS (kind=diff)
Inspect one jobGET /mgmt/diffs/{id}
Retrieve a result pageGET /mgmt/diffs/{id}/rows?offset=0&limit=100
Request cancellationPOST /mgmt/diffs/{id}/cancel
Remove a terminal job and its resultDELETE /mgmt/diffs/{id}

Wait for ready before reading results. cancelling means work has not yet stopped; wait for cancelled before deleting. Other states are queued, running, failed and expired. Detailed failure diagnostics are in the node log. A failed job is not a partial successful diff.

Pages return rows and next; pass next as the subsequent offset until it is null. A row has change and data: change is vertices_added, vertices_removed, vertices_moved, vertices_changed, edges_added or edges_removed. Data is the corresponding core SnapshotDiff record; property values are typed JSON, e.g. {"Integer":42}. This format is not the synchronous procedure’s flat row schema. The existing comparison contract does not report secondary-label changes or edge-property-only changes.

Operational limits

Small forward ranges of property/secondary-label updates can compare only the affected existing vertices when the complete logged transition is provable. Existing snapshot point indexes are reused; without an index, a baseline scan still occurs but only selected vertices are retained. Structural mutations, snapshot-only transitions and unsupported ranges retain full comparison. This optimization is not the AS OF query engine and does not change its paths.

All operations require global Admin plus database authorization. Do not expose them as raw exports to application users with only label-level permissions. Jobs/results belong to the submitting node; polling another node is not failover. Queued and running jobs fail on restart rather than silently changing their source. Completed results survive restart, expire after 24 hours, and are not a backup or replicated audit record. Resubmit after losing the node.

Persisted state names remain queued, running, ready, failed, expired, cancelled and cancelling. An unrecognized state in the local job manifest is reported as an error; it is not silently shown as a failed job or rewritten. The manifest and result files are retained for diagnosis.

There are at most 16 retained jobs and one executing diff per node. Delete terminal entries when the queue is full. Diff execution does not take the tag/backup execution slot, but it still competes for host resources. Active comparison protects required history from destructive retention; queued jobs do not pin history and may fail if their endpoints are pruned before admission.

The default PATINADB_DIFF_MAX_BYTES is 4294967296 bytes of cumulative allocation accounting, not a hard RSS limit. It covers both endpoints and charges replay work without refunding deletions. Larger graphs or long update chains may need an operator-selected larger value and adequate host memory. This setting also applies to synchronous structural diffs. The engine compares snapshot records and checked deltas without constructing two temporary graph databases, but still holds comparison maps in RAM; there is no spill-to-disk fallback.

Jobs have a one-hour execution deadline. Results plus index are capped at 1 GiB, each encoded row at 1 MiB, and artifact writes check a 1 GiB free-space reserve. Pages contain at most 100 rows / 4 MiB encoded row payload; four page readers may be admitted concurrently. Cancellation is cooperative, not an immediate kill. Resource limits and damaged/missing history produce errors, not incomplete data.

The #1294 local 1M qualification covers the unindexed bounded update path, the structural fallback, near/distant endpoints, concurrent traffic, cancellation and restart using an explicitly larger accounting budget, not the default. Unindexed scans still take time; structural fallbacks still need full comparison maps. Queueing alone is not the bounded update optimization. See docs/history-diff-1294-2026-09-12.md in the source tree for measured intervals, resource use and failed attempts. No production approval follows from this single-host qualification.

History epochs (RFC-0038)

A history epoch is a contiguous section of one database history. It is written once, as a verified, immutable archive object, to a volume the operator provides. After an explicit, cluster-wide hot release, the members remove the epoch’s delta and snapshot bodies from local (“hot”) storage. AS OF reads, diff and tag exports of released history are then served from the archive.

Everything is opt-in. Without a policy or an operator request nothing is cut, nothing is released and nothing is deleted.

Requirements

  • Every cluster member runs a release with peer protocol 18. Sealing is refused until all members are confirmed at 18. Once the cluster holds any epoch record, a new member must also speak 18.
  • The database has an incarnation. A database gets one automatically once the cluster is confirmed at protocol 17.
  • PATINADB_EPOCH_ARCHIVE_DIR is set to the same absolute directory path on every member, and every member sees the same shared volume. patinadb-raft --check-config rejects a relative path. The value is node-local; it never enters Raft.

A shared volume is not a backup. Losing it loses the archived copy, and after a release that copy is the only one. Back the volume up yourself, or take self-contained portable backups (see Backups and restore).

Sealing an epoch

An operator cuts an epoch:

curl -X POST http://leader:7474/mgmt/epochs \
  -H 'content-type: application/json' \
  -d '{"database": "sales", "upper": "<engram id>", "request_id": "<optional uuid>"}'
  • upper must be an engram on the database’s current history chain.
  • The first epoch starts at the chain root. Every later epoch starts exactly where the previous epoch ended. The server chooses that lower boundary.
  • The leader validates the whole section against its own history and refuses if it cannot vouch for it, for example because of a history floor or fence.
  • The call requires the admin role and access to the database, and is recorded in the audit log.

While an epoch is sealed, squashes of that database are refused, because a squash would rewrite the history being archived:

  • CALL patinadb.squash fails with a non-retryable conflict.
  • Prepared squash jobs are refused.
  • Licence- or retention-driven squashes are deferred and logged.

Ordinary writes continue.

Automatic cuts (policy)

A per-database policy can cut epochs automatically. It is off by default and has no shipped thresholds:

curl -X PUT http://leader:7474/mgmt/epochs/policy \
  -H 'content-type: application/json' \
  -d '{"database": "sales", "max_engrams": 100000, "max_age_days": 90, "release_after_days": 7}'
  • max_engrams cuts once that many engrams follow the previous epoch.
  • max_age_days cuts at the newest engram at least that old.
  • With both set, the newer boundary wins.
  • release_after_days releases a committed epoch automatically after that grace period. Without it, release stays a manual step.

Every field is optional; a request without any field clears the policy. GET /mgmt/epochs/policy?database=sales shows it. The leader evaluates policies periodically and only while no seal is open. Engram timestamps only decide when to cut; the boundaries come from the ancestry. If HEAD’s timestamp lies more than five minutes in the future, or timestamps inside the candidate section run backwards, the age trigger is skipped and GET /mgmt/epochs/status reports clock_insane. The count trigger still applies.

Lifecycle

StateMeaning
sealedThe selection is fixed and squash is blocked. The leader is exporting it.
archive_committedThe verified object is published and its digests are in the replicated catalog. Hot data is unchanged.
release_blockedA release attempt failed. Archive and hot data are kept.
hot_releasedEvery member verified the archive; members removed the epoch’s hot bodies.
cancelledWithdrawn before publication with POST /mgmt/epochs/{seal_id}/cancel.
supersededThe database was dropped or restored (it got a new incarnation) before publication.

The leader exports a sealed epoch in the background, on the same lane as other maintenance artifacts, publishes the object and then commits the digests through Raft.

GET /mgmt/epochs?database=sales lists the catalog. On the serving node it also shows local progress of export, release barrier and local release: the attempts, the last error and whether work is running. Failed attempts are retried with increasing backoff, up to 10 minutes.

POST /mgmt/epochs/{seal_id}/verify checks the archive on the serving node. It returns verified, missing, corrupt, not_configured or not_committed. The result applies to that node only.

Hot release

curl -X POST http://leader:7474/mgmt/epochs/<seal_id>/release

Release is strictly all-member:

  1. The leader asks every current member, learners included, for a receipt. Each member verifies the archive object completely (length, SHA-256, structure and identity) before it answers.
  2. Only with a fresh positive receipt from every member does the leader propose the release. Apply re-checks the membership and that no tag or pin was created in between. A member that is offline holds the release up; there is no quorum shortcut.
  3. Each member applies the release locally, after verifying the archive itself. It keeps a hot snapshot at the epoch’s upper boundary, indexes the range as released, then removes the range’s delta and snapshot bodies. Each step is durable before the next, so after a crash the node holds either the old hot state or a state the next attempt completes.

Releases go strictly in chain order; the released part is always a prefix of the history.

The release blocks (release_blocked, archive and hot data kept) when:

  • a member reports that it cannot read the object (missing, wrong digest, wrong identity);
  • a tag or pin lies inside the range (tags stay hot);
  • receipts are missing for longer than the block period.

A blocked epoch needs a new POST …/release, or with release_after_days a new automatic attempt after another block period.

VariableDefaultMeaning
PATINADB_EPOCH_RECEIPT_VALIDITY_SECS600How long a set of receipts stays valid.
PATINADB_EPOCH_RELEASE_BLOCK_AFTER_SECS3600How long receipts may stay missing before the release blocks.

After a release:

  • AS OF, patinadb.diff, snapshot diff and tag exports of released engrams hydrate a temporary, fully indexed store from the verified archive segment. At most two hydrations run per process; requests keep their deadline.
  • Reads newer than the released range use the hot snapshot at its upper boundary and never touch the archive.
  • A missing or unreadable volume fails the read with HISTORY_ARCHIVE_UNAVAILABLE (retryable). A damaged or mismatched object fails with HISTORY_ARCHIVE_INTEGRITY. A read is never answered from HEAD or with an empty result instead.
  • CREATE TAG (also on HEAD when HEAD is the released boundary) and pins into released history are refused. Apply decides this against the replicated release, so a tag committed after the release is refused even before members have removed their local bodies; a tag committed first refuses the release instead.
  • A fork whose fork point lies in released history is refused.
  • CALL patinadb.squash and prepared squash jobs are refused for that database. A retention squash whose cutoff lies above the released range still runs.
  • A retention cutoff inside the released range raises the history’s visibility floor instead of squashing. Reads below it fail with HISTORY_EXPIRED, even though the archive still holds them.

New members

Once anything is released, a new member is admitted only after it returns positive readiness receipts for every released epoch it may have to serve: those of each database’s current history version that retention has not expired entirely. A learner that joins from a physical snapshot backfills the released range’s metadata from the archive, not from peers, and the install restores the retention floor, so expired targets answer HISTORY_EXPIRED on the new member as well. For fully expired epochs the leader publishes a metadata-only artifact and commits its hash and length through Raft (protocol 20 required). A new member verifies this artifact before completing its chain, even after the full archive was collected. This does not restore expired query results or synthesize missing deltas (#1428).

For archives collected by older releases, a node with complete retained metadata can still publish the artifact. If neither the full archive nor an authoritative metadata source remains, backfill stays incomplete and retries; no missing ancestry is invented.

Archive collection

Nothing in the archive is deleted unless PATINADB_EPOCH_GC is set. The collector runs on the leader, at most once an hour, and removes only:

  • staging files older than the grace period;
  • objects no catalog entry references (leftovers of cancelled or superseded seals, duplicates), older than the grace period;
  • with PATINADB_EPOCH_GC_BELOW_RETENTION also set: archives that lie entirely below their history’s retention floor, only after their replicated metadata artifact has been verified locally. Missing or corrupt metadata prevents full archive deletion. The metadata artifact remains referenced for the lifetime of its catalog record; orphaned metadata follows the ordinary grace period (#1428).

It never removes an object that a catalog entry of any incarnation (including an imported reference), an archive hold or a running export refers to. Licence loss or expiry never deletes anything. PATINADB_EPOCH_GC_GRACE_SECS sets the grace period (default 604800, seven days).

GET /mgmt/epochs/status shows the configured volume, trigger evaluations and the last collection pass.

Backups and restore

A ?history=true portable backup is self-contained by default: it re-embeds every released epoch from its verified archive segment. It restores on a cluster without any archive volume, and the restored history is entirely hot. Taking it needs a readable archive; without one the backup fails with HISTORY_ARCHIVE_UNAVAILABLE rather than produce a shortened history. History below a retention floor is not exported; the oldest visible engram becomes the restored history’s root.

GET /mgmt/snapshot?history=true&references_only=true keeps released epochs as references instead. The backup carries their catalog records, the hot snapshots at their boundaries and the retention floor, and places an archive hold on the referenced objects. List holds with GET /mgmt/epochs/holds and remove one with DELETE /mgmt/epochs/holds/{hold_id} once the backup is no longer needed.

A restore of a references_only backup:

  • needs every member at protocol 18;
  • verifies every referenced object on the serving node first. It is refused before anything is written when PATINADB_EPOCH_ARCHIVE_DIR is not set or an object is missing or damaged (HISTORY_ARCHIVE_UNAVAILABLE in the message);
  • imports the epochs into the restored database’s new incarnation as read-only references to the source objects. They are never collected and never overwritten by the restored database.

references_only applies to GET /mgmt/snapshot JSON backups only. format=archive backups and asynchronous backup jobs always re-embed. An offline patinadb-admin archive backup has no archive reader, so it fails for a database with released history; take that backup online.

Archive layout and offline verification

<root>/v1/<incarnation>/g<history generation>/<seal_id>/segment-<sha256>.bin
<root>/v1/<incarnation>/g<history generation>/<seal_id>/manifest-<sha256>.json
<root>/.staging/

Object names are their SHA-256 digests. An existing object is never overwritten. A second export of the same seal, for example after a leader change, accepts an existing file only if it verifies to exactly the same content.

Verify an object offline, without a server:

patinadb-admin epoch-verify /srv/epochs/v1/.../manifest-<sha256>.json --replay

The command checks the manifest digest against its file name, the body length and digest, the record format and the parent chain. --replay also rebuilds the newest state of the epoch in a scratch directory (--scratch, by default the system temporary directory) and reports the number of vertices and edges.

Limitations

See Limitations.

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.

For a long-running comparison, administrators can explicitly submit a historical diff job, observe it in SHOW JOBS, and retrieve a paginated result. Synchronous diffRange does not automatically change into a job and still honors query deadlines. Both paths share the direct snapshot/delta comparison engine and its allocation-accounting budget.

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

Time Travel

Historical reads use recorded snapshots and forward changes, not guessed undo against the current graph. For a selective node-property filter followed by a plain WITH node and one typed relationship hop, reconstruction can retain only the candidate nodes and their needed neighbors (#1261). The original query still evaluates all filters and OPTIONAL MATCH behavior. Broad or unsupported shapes fall back to the existing reconstruction path; this is not a universal cold-read latency guarantee. See limitations for qualification status.

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 is an ERROR, not the empty graph (changed in issue #590). AS OF an id that is not in the database’s history — a typo’d or stale id, or one squashed away by retention (see Engrams) — fails with an actionable unknown engram … error, so your application can always tell “that point in history is gone” apart from “the query matched nothing”. (Only the low-level Dataset::reconstruct API keeps its empty-on-unknown behaviour; the user-facing AS OF / restore entry points validate the id first.)

Even so, if your application time-travels to ids it stores, prefer a tag — it is pinned against squash, so the point it names stays reachable and SHOW TAGS tells you whether it is still there.

Damaged history is an ERROR too — never a smaller graph (issue #1145). The engram history is a chain: each point is reconstructed from the nearest stored snapshot plus the deltas above it. If any of those pieces is missing — a lost chain link, a lost delta, a snapshot file that went away while its metadata still claims it — the read fails, naming the engram it could not resolve.

It did not used to. Each missing piece read as “nothing here”: a lost chain link looked like “no snapshot in this history” and replay restarted from an empty graph, a lost delta replayed as “that commit did nothing”. The answer was a graph that never existed, returned with no error at all — measured in one real case at 5 nodes where 40 were correct. Nothing distinguished it from a genuinely small result.

You should never see this: it means storage damage or partial data loss, not a normal condition. What matters is that you now see it as an error instead of as data. Take a backup restore seriously if it appears — and note that a live (non-AS OF) query is unaffected, since it reads the current graph directly rather than replaying history.

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.

A tag requests a snapshot at its engram. Once that snapshot is available, reconstruction can start there without replaying earlier deltas. Raft-server materialization is asynchronous: acknowledgement is not snapshot readiness, and a snapshot does not guarantee fast queries. See the unresolved 1M historical workload findings in limitations.

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 its engram and requests a snapshot:

  • Pinned — a tagged engram is protected from squash: compaction never collapses it, so the point-in-time it names stays reachable.
  • Snapshot preparation — on a Raft server, a background job builds the full snapshot. Until it completes, reads use existing checked reconstruction; incomplete history is reported rather than replaced by the current state.
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 and pins the same engram. Snapshot preparation finishes independently on each node. AS OF TAG works where the required history is available, including followers. SHOW TAGS is a local read; CREATE/DROP TAG need admin and go through the leader. The returned bookmark covers tag creation, not snapshot completion. Use admin-only SHOW JOBS on the target node to inspect preparation. Embedded Dataset::create_tag remains synchronous.

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 level — what 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. When Anamnesis is disabled for the target database, a non-empty context is DROPPED — not stored, not queryable, gone (issue #1071). This is silent by default, on purpose (existing deployments are unaffected unless they opt in to the signal below), but it used to be silent with NO way to notice — a client faithfully sending {"actor": "enduser-4711"} on every write could believe it had months of attribution, right up until the first incident revealed Anamnesis had never been turned on for that database (or was turned on for a DIFFERENT one — enablement is per-database, and a newly created database always starts disabled). Two things now make the drop observable instead of invisible:

  • Response signal. A write whose provenance/tx_metadata map is non-empty gets a provenance_recorded: true|false field in its response (REST body; a write with no such map carries neither key, so a client not using the feature sees no change). false means it was dropped.
  • One warning per database, per node. The FIRST time a node sees a non-empty context land on a database without Anamnesis enabled, it logs a warning naming the database — once, not once per write (this deployment profile is 99% writes carrying the same context shape, so “once per write” would be log noise, not a signal).

If silent loss is unacceptable for your deployment, set --provenance-required (PATINADB_PROVENANCE_REQUIRED, off by default): a write carrying a non-empty context to a database without Anamnesis enabled is then refused outright (409, before anything is proposed — the graph is untouched) instead of silently dropped. Scope, honestly: this flag is enforced on the REST autocommit write path only today; Bolt writes (autocommit and explicit BEGIN…COMMIT transactions) are not yet covered — threading the check through Bolt’s result-streaming state machine is a larger, separate change than this issue’s “minimal, cheap” scope justified in one pass. A write with NO context is never affected by this flag, on either transport.

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. Use a long random value — this username is exempt from the failed-login throttle (issue #1065), so a short password has no guess-rate cap; see Authentication.

--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 {name: $name}) RETURN n.name AS name LIMIT 10",
  "parameters": { "name": "Ada" },
  "db": "default",
  "at": null,
  "consistency": "local"
}
FieldRequiredMeaning
queryyesThe Cypher (or DDL) statement.
parametersnoNamed $param bindings (default: none). See below.
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.

parameters — a flat JSON object of named values, bound the same way Bolt drivers have always bound $param (issue #899: REST had no way to pass parameters before this — a request had to embed every value directly into query as a literal). Works everywhere a literal is valid (MATCH, WHERE, SET, RETURN, …), on both reads and writes, and composes with at (a parameterized time-travel read is one request, not two features that happen to coexist).

Prefer parameters over embedding values in query for two reasons that matter beyond style:

  • It is a real bind, never textual substitution. The query is parsed with $name intact; the value travels as data and is resolved at the AST/eval layer, so it is structurally incapable of being read as Cypher syntax — the same injection defense patinadb-indexer (and every Bolt driver) already relies on for user-supplied values. Embedding a value into query yourself reproduces exactly the string-concatenation risk that defense exists to avoid.
  • It lets repeated requests actually hit the L3 result cache. L3 keys a cached result on (query text, parameter values); two requests with the same query text but embedded-literal values that differ are, as far as the cache is concerned, two unrelated query texts and can never share admission or a cached hit. The same two requests using parameters share the query text and simply vary the bound values — the shape the cache is designed to reuse.

Omitting parameters is unchanged from before this field existed (an empty bind set) — no existing client needs to change anything.

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.

Read-your-writes over REST: log_index → ?min_index=

For the common “a backend reads back what it just wrote” case there is a cheaper option than linearizable — the REST counterpart of the Bolt driver’s causal bookmarks:

  1. A write response carries the write’s committed Raft log index in the log_index field:

    { "applied": 1, "log_index": 42, "db": "default",
      "stats": { "nodes-created": 1, "properties-set": 2 } }
    

    stats reports the write’s side-effect counters — the same Neo4j-named counters Bolt returns (nodes-created, nodes-deleted, relationships-created, relationships-deleted, properties-set). Only non-zero counters appear, and the key is absent entirely for a pure read. properties-set counts assignment and removal (a SET, a SET n += {…} and a REMOVE n.p each increment it), and no labels-added/labels-removed counter is emitted — see Bolt for why.

  2. Pass that index back as the ?min_index= query parameter on a subsequent read. The serving node then waits (bounded, see below) until its own state machine has applied at least that index, and only then runs the read:

    curl -s -u neo4j:secret -X POST 'http://127.0.0.1:21002/cypher?min_index=42' \
      -H 'content-type: application/json' \
      -d '{"query":"MATCH (n:Person {name:\"Ada\"}) RETURN n"}'
    

    This works on any node, followers included — a caught-up node answers immediately, a lagging follower blocks until the write’s log entry lands. No leader pinning, no read-index round-trip; the client only needs to remember one integer (track the maximum log_index you have seen, like a driver’s session bookmark).

If the node cannot catch up within the wait window (default 10s; --bookmark-wait-timeout-secs, the same knob as the Bolt bookmark wait; 0 disables the gate and min_index is then ignored), the read fails with a retryable 503 carrying "neo4j_code": "Neo.TransientError.Transaction.BookmarkTimeout" — it never silently serves the stale result. Retry the request (against any member; another node may already be caught up). min_index applies to reads only — writes are leader-only and always observe their own predecessors — and omitting it keeps the default local behavior unchanged.

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"}'

Reading scalar result rows

The JSON query response is column-oriented. For scalar projections, each entry in results[column] is a scalar wrapper with the nil UUID, label __scalar__ and the projected value in properties[column]. For example, the row from RETURN 42 AS answer is represented as:

{
  "results": {
    "answer": [{
      "id": "00000000-0000-0000-0000-000000000000",
      "label": "__scalar__",
      "properties": {"answer": 42}
    }]
  }
}

Other response fields are omitted here. Reconstruct rows by matching array positions across the projected columns; do not compare the wrapper itself to the scalar value. An empty read result can be {}: the response omits results rather than predeclaring columns with empty arrays. Use explicit projection aliases for stable field names. Bolt drivers expose projected values directly instead of these REST wrappers.

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.

Writes are at-least-once

A write that you did not get an ack for may or may not have committed — and retrying it is not transparently safe. If the ack is lost after commit (a 503, a timeout, a connection reset mid-response), resending the statement runs it again as a brand-new execution: a CREATE mints fresh vertex UUIDs on every resolve, so the retry inserts a duplicate. See Client Retry Contract for the rules (UNIQUE constraints / MERGE on a business key, retry-once discipline).

Idempotent writes: the Idempotency-Key header

A write request may carry an Idempotency-Key header whose value is a client-chosen token unique to that logical write (printable ASCII, no spaces, non-empty, at most 128 bytes; an invalid token is a loud client error, never a silent “no token”). If the same request reaches the cluster again — a retry after a lost response, possibly to a different leader — the retry applies nothing and reports where the original landed. The response carries two fields for this:

  • dedup_active (bool) — was the token check active for this write? true means you are protected, not that this write was a duplicate: it is true for every ordinary first-time write that carries a token. It is false when you sent no token, or while a rolling upgrade has the cluster on a mixed protocol version (the protection pauses; writes never stop).
  • duplicate_of (integer, usually absent) — did the check fire? When present, this exact request had already committed; this call applied nothing, and the value is the Raft log index where the original landed (usable as a min_index bookmark). duplicate_of is the duplicate signal — dedup_active never is.

A recognised duplicate is a success, not an error; its stats are all zero, because this call changed nothing. The full contract — token uniqueness is yours and undetectable server-side, the 10-minute / 100 000-newer-writes capacity bound, and the rolling-upgrade pause — is in Client Retry Contract → Idempotency tokens.

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, /mgmt/retire-admin-password, /mgmt/unlock-user (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). An even voter count is refused (409) unless the body is wrapped as {"members": {…}, "allow_even": true} — see Cluster sizing.
POST /mgmt/add-learnerAdd a node as a non-voting learner.
POST /mgmt/change-membershipPromote learners to voters / change the voter set. An even resulting voter count is refused (409) unless the body is wrapped as {"members": […], "allow_even": true}.
GET /mgmt/clusterFull voter/learner topology + addresses + current leader, plus admin_password_rotation_in_progress (this node’s dual-accept grace window state — issue #1071 review follow-up).
GET /mgmt/metricsRaft metrics (leader, term, applied/log indices, snapshot and purge indices).
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.

Since 0.13.0, GET /mgmt/metrics also returns snapshot_index (the node’s current Raft snapshot cut) and last_purged_log_index (its purged-log boundary). Both are nullable unsigned indices: null means no such index is available. Compare these with last_applied when diagnosing learner recovery; a snapshot file’s presence alone does not prove log purge or completed catch-up. These indices do not report asynchronous history completeness.

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 / property-type constraint>] },
  "point_indexes":     { "<db>": [<point index def>] },
  "edge_sorted":       { "<db>": [<edge-sorted index def>] },   // since #553
  "wasm_functions":    { "<db>": [<wasm function def, module bytes>] }, // since #553
  "tags":              { "<db>": [["<name>", "<engram uuid>"]] },  // tag names
  "anamnesis_enabled": { "<db>": <true|false> },   // provenance flag, since #522
  "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 / property-type constraints, compound / vector / full-text / point / edge-sorted indexes, WASM functions (re-registered through the restoring node’s WASM registrar), named tags, the anamnesis flag (re-applied via the replicated SetAnamnesis command, since #522), 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: a consistent cut, no quiesce needed. Under the barrier the backup pins one point in time per database, and every part of that database — HEAD label, graph, and engram history — is read from that pinned point. A write that lands while the backup streams does not leak into the export: the restored backup satisfies the same reconstruct(HEAD) == live graph invariant the source did, and its AS OF timeline is monotone. Writes are briefly serialized only while the point is pinned (one O(1) pin per database), never for the duration of the stream — measured under issue #1105 on a 62 GiB/NVMe box, a 212 MB PITR backup taking 1.73 s moved a concurrent writer’s median latency from 3116 µs to 3333 µs (+7%), with no stall resembling the backup’s own duration (that box was shared, so the measurement is honest about the median and the absence of a stall, not precise enough for a tail-latency figure). The full write-up is in Production Deployment → Backup & disaster recovery — including the caveat for older builds predating the fix, where a backup taken under load could be silently torn and quiescing writes was required.

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).
  • format — csv, 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 / point / edge-sorted index defs and WASM function defs (the full definition-family set, since #522), 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, tags, and the anamnesis flag.)

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). The anamnesis flag is re-applied per database via the replicated SetAnamnesis command (since #522 — only true is re-applied; a merge-restore never silently disables a flag). 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. ?force=true lifts the refusal so the restore may proceed — but it is still an additive merge: the backup’s ops are replayed on top of the existing data and nothing is ever deleted. There is no “wipe and replace” mode on this endpoint; for that, restore onto a freshly bootstrapped, empty cluster instead (see Disaster Recovery). An absent or already-empty target database restores with no flag needed — the common fresh-restore case is unaffected. PITR is stricter: if the backup carries engram history (?history=true), installing it onto a database that has data or a timeline of its own is refused with or without force — a history restore requires a pristine target.

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. Unless the backup was taken with ?history=true (see /mgmt/snapshot above), it is a HEAD-state dump, not a PITR archive: 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). With ?history=true the whole timeline is restored — but only on the node that received the restore: the HEAD graph replicates to followers via Raft, the historical delta/snapshot bodies do not. If that node later dies, the restored timeline is gone until you restore it again (see Disaster Recovery). The anamnesis-enabled flag is carried and re-applied on restore (since #522) — but only future writes are projected from that point on; the restored graph itself is not retro-projected into the <db>__anamnesis companion (the companion rides the backup as ordinary data). The backup is leader-anchored, HEAD-labelled, and a consistent cut — each database is read from one pinned point in time, so cross-database consistency holds even while writes continue (no quiesce needed; see /mgmt/snapshot above).

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

Changing authentication on an existing connection discards its pending cursor and uncommitted explicit transaction. A failed LOGON never retains the previous identity; authenticate successfully again before running queries. LOGOFF is not a way to transfer an open transaction to another user (#1265).

Directory-user sessions also expire when that user’s password, roles or grants change, the user is deleted/recreated, or a snapshot replaces the directory. At the next authenticated message, including PULL or COMMIT, the server discards pending results and uncommitted work and returns Neo.ClientError.Security.Unauthorized. Reconnect and authenticate again (or RESET then LOGON on a protocol version supporting it). Do not retry a permanent permission denial blindly. The check sees locally applied user state; it neither preempts an in-flight message nor makes follower revocations instantaneous. Root-admin and auth-disabled sessions are unaffected (#1265).

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.

Decode resource limits

In the post-0.13.0 audit update (#1274), each decoded message has a shared budget of 128 MiB of requested payload/container allocations and 1,048,576 decoded values, including map keys and the root value. These are policy ceilings, not measurements of total process RSS or a pre-authentication connection-count limit. The existing 64 MiB wire-message ceiling and nesting limit remain separate. Exceeding a decode budget is rejected as a malformed message; split oversized collections across requests instead of retrying the same payload. String/byte payload copies and container reservations share the budget, so nested values do not each receive a fresh allowance.

Completed connection tasks are collected while the listener continues serving new connections (#1272), not retained until process shutdown.

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 advertises Bolt 5.8 down to 5.0, plus 4.4, and negotiates the highest version the client also offers. 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; project elementId(n) / elementId(r) in Cypher instead if you need a stable identifier over such a client (the legacy integer id field is a lossy 63-bit hash and is not round-trippable — see Element identity).

Error metadata (GQLSTATUS). A Bolt FAILURE carries the legacy code and message a pre-5.7 driver reads, plus neo4j_code (byte-identical to code), gql_status, and description. neo4j_code is the field a 5.7+ driver uses to populate err.code and decide is_retryable(), so it is the one to branch on and it is exact. gql_status is a best-effort mapping onto the GQL status classes (an unmapped error becomes the general 50N42) and description is derived from the code and message — treat both as diagnostic text, not as a contract. diagnostic_record is deliberately not sent; drivers synthesize their own default. See the Client Retry Contract for which codes are retryable.

Element identity over the wire. For Bolt 5+, a node’s element_id is its UUID string and a relationship’s element_id is the canonical <start>:<end>:<type> handle — both agree byte-for-byte with what Cypher elementId(n) / elementId(r) return, so a value read off a returned entity round-trips as a lookup key. A property named id on a returned node is your own stored property, never the element id (patinaDB does not hijack id); the internal element id is elementId(n) / id(n) / the reserved n.__id. The struct’s legacy integer id field is derived from a lossy hash and must not be used to fetch an entity back.

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.

Temporal values over Bolt

All six temporal types round-trip as native Bolt temporal structs, in both directions — returned as a driver’s own date/time objects, and accepted as query parameters:

Cypher typeDriver type (Python)
Datedatetime.date
Timedatetime.time (with tzinfo)
LocalTimedatetime.time
DateTimedatetime.datetime (aware)
LocalDateTimedatetime.datetime (naive)
Durationneo4j.time.Duration

DateTime uses the Bolt 5.x encoding when the connection negotiated 5.x and the legacy 4.x encoding on a 4.4 connection, so both driver generations read the same instant.

Before 2026-07 this was silently lossy. A temporal passed as a parameter was converted to a debug string, so it was stored as text rather than a temporal — a data-loss bug that failed silently. If you worked around it by pre-formatting temporals as strings, you can now pass the native values, but any data written through the old path is still text and needs converting.

A parameter carrying a struct patinaDB has no type for is now rejected loudly with Neo.ClientError.Statement.TypeError naming the tag, rather than being coerced to a string. Only Point and the six temporal types are accepted as struct-valued parameters. One documented lossy edge remains: a DateTimeZoneId (a named zone such as Europe/Berlin) decodes to its offset, so the zone name is not preserved.

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.

Bolt tx_timeout metadata is a non-negative integer in milliseconds: set it on autocommit RUN or explicit BEGIN. A BEGIN deadline is absolute and is not renewed by later RUN messages. Zero omits the client deadline; it does not disable the server’s configured query timeout. The earlier deadline applies, with cooperative cancellation during execution and historical reconstruction (#1261).

  • 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 (session.execute_write/executeWrite, or equivalent) work as expected, including automatic retry on the conflict error below — but a plain autocommit RUN outside a managed transaction is never retried by any driver, in any version. If your backend issues autocommit writes under contention, it must implement its own retry loop (see Client Retry Contract); this is independent of the conflict-code fix below and true regardless of which patinaDB version you run.

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.General.TransientError), which every official Neo4j driver’s managed-transaction function retries automatically (session.execute_write/executeWrite, or equivalent). 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). A losing leadership mid-COMMIT surfaces the same driver-retryable code, for the same reason: it too is a routine event a well-behaved client simply retries.
  • Autocommit (a bare single-statement RUN) holds the per-database write lock for the statement, so autocommit writes to one database are serialized on the server side — but a plain autocommit RUN is never automatically retried by the driver itself, in any version, since it isn’t wrapped in a managed-transaction function. A client issuing autocommit writes under contention must retry them at the application level.

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.

Idempotency tokens (idempotency_key in tx_metadata)

A write may carry a client-chosen idempotency token so that a retry after an ambiguous outcome (lost response, failover mid-commit) is recognised as an already-committed duplicate instead of applied twice. Set it in the transaction metadata under the reserved key idempotency_key:

  • Autocommit: in the RUN’s own metadata (the tx_metadata argument of session.run(...)).
  • Explicit transaction: in BEGIN’s metadata — session.begin_transaction(metadata={"idempotency_key": …}). The token is a property of the whole transaction and is checked once, at COMMIT; a RUN’s own metadata inside an already-open transaction is not read for this. An invalid token fails BEGIN loudly.

Token rules: printable ASCII without spaces, non-empty, ≤ 128 bytes; you own uniqueness — reusing one token for two different writes silently loses the second. A recognised duplicate is a success that applied nothing; its write counters are all zero, and the response names the original’s log index in duplicate_of. dedup_active only tells you the check ran — it is not the duplicate signal.

Observability caveat for explicit transactions. The dedup_active / duplicate_of fields ride the COMMIT message’s SUCCESS metadata, which the official Python driver behind session.execute_write does not expose to application code — the ResultSummary you can reach belongs to the buffered statements and reports dedup_active: false. The protection works; you just cannot see it over that API. If you must observe the dedup decision, use an autocommit statement (its terminal SUCCESS is reachable) or the REST surface. Full contract: Client Retry Contract → Idempotency tokens.

Side-effect counters (ResultSummary.counters)

Every write’s Bolt SUCCESS metadata now carries a stats map — the same Neo4j wire keys the official driver hydrates into ResultSummary.counters (the wire keys are hyphenated — nodes-created, nodes-deleted, relationships-created, relationships-deleted, properties-set — and a driver surfaces them as counters.nodes_created and so on; contains_updates is derived client-side from any non-zero counter, so it is never sent explicitly). Only non-zero counters are sent. It is folded from the already-resolved DeltaOp batch for the statement (or, for an explicit BEGIN … COMMIT transaction’s COMMIT, the whole transaction’s accumulated batch) — so it reflects exactly what was applied, not a guess — and is omitted entirely for a pure read (no stats key at all, matching Neo4j: a read never “contains updates”).

Two counting decisions worth knowing:

  • properties_set covers both assignment and removal. Neo4j has one property counter, not separate set/removed ones, and a SET n.p = null removes the key in patinaDB’s model — so a SET, a SET n += {...}, and a REMOVE n.p all increment properties_set.
  • labels_added/labels_removed are never emitted. SET n:Label / REMOVE n:Label carry the vertex’s full post-batch secondary-label set in the resolved op, not a delta against what it held before, so there is no way to derive “how many labels were added” from the op alone without re-reading prior state. An absent counter (the driver’s own default of 0) is preferable to a fabricated number.

labels_added/labels_removed, indexes_added/indexes_removed, constraints_added/constraints_removed, and system_updates are not populated (DDL isn’t tracked by this mechanism yet); a write that only changes labels or schema will still report contains_updates correctly via properties_set/nodes_created/etc. where applicable, but won’t itself set those specific counters.

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.

If BEGIN cannot open its snapshot-isolation base before creating a transaction, it returns Neo.TransientError.General.DatabaseUnavailable with patinadb_effect: "none". After a successful RESET, the client may retry that BEGIN with the same database and bookmarks. A missing no-effect receipt, a transport failure, or a failed COMMIT does not establish that retrying a write is safe. Snapshot-open failures never fall back to weaker constraint enforcement.

The message tells the two causes apart. When a concurrent write is publishing (normal under write load), the message ends with a complete native read snapshot is unavailable; retry the read. Any other cause ends with internal server error, and its detail stays in the server log. Both carry the same code and patinadb_effect: "none", so the retry rule above holds for both. The same publication message is returned when a RUN read is refused for this reason, matching REST’s 503 READ_PUBLICATION_IN_PROGRESS body.

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. A managed transaction may retry, and routing may select a more caught-up eligible member; bare session.run() does not provide that managed retry loop — never substitute an unbookmarked read. Set --bookmark-wait-timeout-secs 0 disables legacy-index waiting only. Scoped tokens never bypass their barrier; compatible/strict mode requires a positive timeout.

Scoped bookmarks and rolling upgrades

--causal-bookmark-mode legacy is the compatibility default. compatible emits neo4j:patinadb:v2:<domain-uuid>:<index> and also accepts retained legacy tokens; strict rejects legacy Patina tokens. Malformed recognized tokens and foreign causal domains fail explicitly. Unrelated vendors’ bookmark namespaces are ignored. Upgrade every member before enabling scoped mode and enable it on every intended client endpoint. Initialization commits one shared domain through Raft. Election, restart and same-cluster snapshot onboarding preserve it. Domain initialization is not reversible through normal configuration; binary downgrade afterwards is unsupported. Never send scoped clients to an old binary that ignores the format.

An Engram UUID identifies history, not an applied prefix. Historical bodies may still be backfilling when a current-state read satisfies a scoped bookmark. Across sessions, pass bookmarks explicitly or use the driver’s bookmark manager; independent sessions without shared dependencies are not causally linked.

A history-only AS OF / request-at read still passes the causal admission barrier, but deliberately returns the requested historical state. Its summary preserves only incoming dependencies (index 0 if absent), not the newer current applied position. Mixed historical/current-read transactions retain their conservative BEGIN frontier; writes retain their committed effect position and incoming dependencies.

Python backend: routing, retries and request dependencies

Use one shared driver but a separate session per request/thread. In a routed cluster, select READ_ACCESS/execute_read for reads; the default write mode is not a request to load-balance over regional readers. Across HTTP backend instances, serialize the driver’s opaque bookmark strings and pass them into the next session. Retain independent workers’ frontiers separately, and combine them when joining their work; do not parse or numerically compare tokens.

For a driver created with a bounded max_transaction_retry_time, a read can use:

from neo4j import Bookmarks, READ_ACCESS, unit_of_work

@unit_of_work(timeout=5)
def read_ticket(transaction, ticket_id):
    result = transaction.run(
        "MATCH (ticket:Ticket {id: $id}) RETURN ticket.title AS title",
        id=ticket_id,
    )
    return [record.data() for record in result]

with driver.session(
    database="default",
    default_access_mode=READ_ACCESS,
    bookmarks=Bookmarks.from_raw_values(incoming_bookmarks),
) as session:
    rows = session.execute_read(read_ticket, requested_id)
    outgoing_bookmarks = list(session.last_bookmarks().raw_values)

The timeout belongs on unit_of_work for managed transactions or on begin_transaction(timeout=...) for explicit ones. Autocommit uses session.run(Query(text, timeout=...)); transaction.run takes query text, not a Query object. Passing timeout as an ordinary run parameter does not configure the transaction deadline. See the official Python driver’s transaction API and bookmark guidance.

Consume results inside the callback and avoid external side effects: managed callbacks may execute more than once. Retry budgets do not replace socket, connection-acquisition and application deadlines. local-only cannot fall back to a writer outside the selected region; if its readers cannot satisfy a bookmark, surface bounded unavailability instead of silently weakening consistency.

An older bookmark cannot settle a lost commit acknowledgement. A later read may observe the write, but its absence does not prove the write was aborted. For safe write retries, use the documented idempotency-token contract or explicitly surface an unknown outcome; do not blindly repeat an increment.

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. An idle connection closes without an unsolicited Bolt response when this timer expires. A connection in an otherwise healthy driver pool can legitimately expire; applications should validate pooled connections before reusing them after a long idle period. For the Neo4j Python driver, configure liveness_check_timeout (the sample backend uses 60 seconds; 0 checks every reuse). This checks the socket before dispatch, not replaying a write whose commit outcome is unknown. See the driver configuration reference. Server value 0 disables this idle reaper; disabling resource guards is not required for pooling.
  • 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.

Operator termination through /mgmt/transactions/{id}/terminate also wakes an idle transaction owner, rolls back its pending work and closes its connection. This does not depend on the idle reaper being enabled. Active work continues to observe the cooperative termination flag.

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.

Anything the engine actually implements reaches the engine. The shim never shadows a real feature: CALL db.labels() / db.relationshipTypes() / db.propertyKeys(), CALL db.index.fulltext.*, CALL db.index.vector.*, and the whole SHOW family the engine supports — SHOW CONSTRAINTS, SHOW INDEXES, SHOW POINT INDEXES, SHOW EDGE SORTED INDEXES, SHOW FUNCTIONS — all pass straight through and return real data. (Until 2026-07 the shim swallowed the SHOW family and answered empty-success, so SHOW CONSTRAINTS over Bolt reported no constraints on a database that had them. It now reports them.)

Still canned: SHOW DATABASES (answered from the registry), SHOW PROCEDURES (returns an empty list — the engine has no procedure catalogue), and the CALL dbms.* introspection the Browser needs.

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 leader returns itself for all three roles. During leader discovery, WRITE may be empty with a short-lived table; a follower is not a substitute writer. Native ROUTE honors the driver’s requested database (including the neo4j alias), waits for recognized causal bookmarks, then checks closed-mode database authorization against the catalog. Invalid ROUTE bookmark framing returns an argument error; invalid Patina bookmark token contents return a bookmark error. Bolt user impersonation is not implemented; a ROUTE request carrying imp_user fails explicitly instead of silently routing as the authenticated service account.

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.

PatinaDB Console

The console groups Query and Explore, temporal views (History, Anamnesis and Live changes), and administrator tools. Graph exploration supports node inspection, manual layout, zoom and Fit graph. Relationship labels appear around a focused node; History provides Engram details and historical queries.

In server mode with administrator credentials, Admin overview reports cluster membership, query activity, storage and cache metrics. Voting membership is separate from leader/follower role; learners do not vote. Region badges appear when the status response supplies a region.

License & telemetry reports the connected node’s loaded license, exact UTC expiry, remaining days, signed feature flags and effective entitlements. Feature entitlement does not imply that the corresponding runtime feature is configured. Telemetry’s configured state is shown separately from permission to opt out. No license token, customer identity or license path is exposed. Older servers without the reporting endpoint show unavailable information. This is not a cluster-wide license audit.

DB Archaeology combines Oxidation’s UTC activity mosaic with scrollable database Engram timelines. Selecting a day synchronizes both views. Collection samples at most the latest 500 Engrams per database, uses bounded request deadlines, and reports partial errors. Missing history is not proof of zero activity; the view is not a complete historical census or a graph-mutation counter.

The console’s read-only /admin/license and /admin/archaeology routes require server mode and administrator access. The upstream /mgmt/license endpoint is protected by the server’s operator authorization. Homepage images use synthetic demo data; they do not represent a live cluster or production telemetry.

Identity & Access and operator sessions

The Identity & Access view shows the signed-in operator’s effective access. Global administrators can manage local users and service accounts, direct database roles, permission profiles, provider bindings and subject blocks. External principals and provisioned groups are inspected read-only; their lifecycle stays with the identity provider. Lists are paginated, edits carry revision preconditions, and save conflicts require a reload. Nested permission and provider settings use labeled JSON fields. The preview explains the server’s effective-access decision; it does not replace authorization on subsequent requests.

Basic local accounts and database roles remain Community functionality. Fine-grained access expansion requires the applicable RBAC capability; external identity integration requires IAM. The server evaluates each mutation, including whether it reduces or expands access. Emergency disable and revocation controls remain accessible in the UI. Removing a subject block can expand access and is not an unconditional revocation operation. Real Entra, Okta and Keycloak deployments are not yet qualified by the automated mock-provider tests.

For multiple operators, start the Console in REST server mode with --sessions. Do not supply a shared --user or --password. Every operator’s credentials stay in Console process memory, and each session and refreshed credential generation uses its own upstream connection pool. The browser receives an opaque HttpOnly cookie, not an API or refresh token. Embedded and Bolt-only modes do not support this session model and show an explicit unsupported-mode explanation.

Server mode uses the single database configured by --database for Console queries. To use another database as the Console’s default, run a separately configured Console process; one Console session does not select a different database per user.

Hosted deployments require an HTTPS public origin (--public-origin) when the listen address is not loopback. Serve the Console behind an HTTPS reverse proxy; --listen itself does not enable TLS. The upstream REST server must use HTTPS unless it is loopback HTTP. --insecure-dev-cookies is restricted to loopback listen addresses and loopback public origins, even when that origin uses HTTPS.

The session defaults are 30 minutes idle, 12 hours absolute lifetime and five minutes of recent authentication. Configure these with --session-idle-secs (default 1800), --session-absolute-secs (43200) and --session-recent-auth-secs (300). Values must be positive; idle and recent-auth windows cannot exceed the absolute lifetime. The bounded store holds at most 10,000 sessions; least recently seen entries are evicted when needed. Authentication network operations share a nonwaiting limit of 32; saturation returns 503.

Every protected request re-verifies the upstream identity and effective access; logout removes the session without requiring a network check. Password reset, suspension, credential revocation and identity replacement end retained sessions at the next request. A changed effective-access fingerprint, including database roles, grants and strictness modes, retires the old session ID and requires a fresh login with a new ID. Individual upstream operations still enforce current authority; the Console fingerprint is not a substitute for server authorization. Existing local accounts need their replicated stable principal IDs assigned by the v18 migration before Console login; missing IDs return identity_migration_pending rather than accepting a display name as identity.

The current candidate revalidates authorization at /changes delivery boundaries, including replay, live delivery, and resynchronization. Expiry, revocation, or an unavailable check terminates the stream with an SSE error and clears buffered events. Targeted expiry and revocation regressions passed in the source-bound local validation. This does not promise instantaneous cluster-wide revocation before a change is committed; PR CI verification remains.

The browser uses these same-origin routes:

  • GET /auth/session reports authentication state and a per-session CSRF token.
  • POST /auth/login accepts local credentials; successful login replaces the previous session. The UI also offers explicit local break-glass sign-in.
  • POST /auth/logout removes the session. Logout does not revoke independently issued API tokens.
  • GET /auth/oidc/start and /auth/oidc/callback implement organizational sign-in.
  • /me/access forwards the operator’s own /iam/me response; /admin/iam/* forwards management requests with that operator’s authority.

State-changing requests carry x-patina-csrf; identity-management writes also require recent authentication. A break-glass indicator comes from the server’s explicit root identity, not from a global Admin role. Service API keys and SCIM credentials are displayed once; the UI does not persist them in browser storage. Session responses prohibit caching.

Organizational sign-in configuration

Configure --oidc-issuer, --oidc-client-id, --oidc-client-secret-file, --oidc-redirect-uri and, where needed, --oidc-scopes. The redirect must exactly match the public origin plus /auth/oidc/callback. The scope set must include openid and obtain an access token authorized for the Patina API. --oidc-algorithms defaults to RS256; supported explicit choices are RS256, PS256, ES256 and EdDSA. Algorithms are never selected from untrusted token claims alone.

Use --oidc-ca-file for an additional private-provider CA bundle and repeated --oidc-allowed-network values for explicitly trusted private CIDRs. Development HTTP IdPs additionally require --oidc-allow-insecure-http; metadata destinations remain refused. Every resolved address is checked and pinned, proxies and redirects are disabled, and TLS hostname validation remains enabled. Discovery and token endpoints must share the issuer’s origin. Separate-origin provider endpoints are currently unsupported. Provider network reads have a ten-second bound and bounded JSON/token sizes; errors do not expose token bodies or secrets.

Sign-in uses PKCE S256, nonce, browser-bound state and a short-lived HttpOnly SameSite=Lax callback cookie. Signed ID-token claims and the API’s authoritative issuer, subject and stable identity must agree. Fresh authentication time comes from the signed claim, not callback receipt. Refresh is serialized per session, requires a newly signed ID token, preserves the original authentication time and cannot restore a session removed during the request. Providers that omit an ID token on refresh fail closed; sign in again instead. This is a conservative compatibility boundary, not a claim that every OIDC provider configuration works.

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=.

What timestamp guarantees. It is unix seconds, stamped by the node that proposed the write, and it is non-decreasing along a database’s history: an engram’s timestamp is never older than its parent’s (issue #1077). That holds across a leader failover even when the new leader’s clock is behind the old one’s, so ETL that sorts or windows by timestamp will not see time jump backwards mid-stream.

Two things it is deliberately not. It is not a unique key — several writes within the same second share a second, and a clamped write repeats its parent’s value — so order events by their position in the stream (or by the engram parent chain), never by timestamp alone. And it is not a precise clock: it reflects the proposing node’s wall clock, so it can sit ahead of or behind your own by whatever your fleet’s clock skew is. For exactly-once resumption use the engram id cursor, which is what it is for.

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.

Snapshot installs (resync from scratch)

When a Raft follower falls too far behind, it catches up by installing a snapshot — a wholesale replacement of the graph and the engram log the change stream reads. A replay that crossed that window would mix the old and new timelines against your old cursor, so /changes fences it the same way ordinary reads do:

  • New subscriptions during the install window are refused with a retryable 503 snapshot install in progress; retry — back off briefly and reconnect.

  • An established stream that crosses an install (mid-replay or while live-tailing) ends with a terminal error event:

    event: error
    data: {"reason":"snapshot_install","resumeFrom":null}
    

    resumeFrom: null means your old cursor named a discarded timeline and is void. Discard any state you built from this stream, reconnect, and replay from scratch (?since=0, or omit since) — the post-install replay delivers the complete new timeline in causal order. (Do not resume from your last pre-install engram id after seeing this event; for an ordinary error/lagged resync the cursor is still valid, but an install replaces the log it pointed into.)

Retention squash (cursor compacted — resync from scratch)

History retention can squash the oldest engrams into a single genesis engram that carries the compacted state, deleting the collapsed prefix. If your resume cursor sits inside that deleted prefix, the server cannot replay the window between your cursor and the squash cutoff — those changes (including deletes) are gone from the log. Rather than silently replaying the squash genesis’s create-everything event on top of your existing state (a permanent, silent divergence of a downstream index or ETL pipeline), the stream ends with a terminal error event:

event: error
data: {"reason":"cursor_compacted","resumeFrom":null}

The same fence fires mid-replay if a squash lands between the server’s history walk and the moment it reads an engram body it still owed you. A sibling reason, cursor_unknown, means the cursor is not on this database’s chain at all (an id from another database, a typo, or a discarded timeline) — it is handled identically:

event: error
data: {"reason":"cursor_unknown","resumeFrom":null}

Both mean exactly what resumeFrom: null says: your cursor is void. Discard the state you built from this stream, reconnect, and replay from scratch (?since=0) — a from-scratch replay always works, because the squash genesis itself carries the complete compacted state. To avoid this, keep your consumer’s lag comfortably inside the retention window (squash keeps a configurable number of recent engrams), or pin the engram your pipeline is anchored to.

A caught-up consumer is never fenced: subscribing with since= equal to the current HEAD (or omitting since) is an empty replay followed by the live tail, exactly as before.

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.
  • neo4j is an alias for default. Neo4j’s own default database is called neo4j, and the official drivers send that name when a session doesn’t specify one — which used to fail with DatabaseNotFound on the very first statement. neo4j now resolves to default everywhere a database is named: the Bolt db field, USE neo4j, and the REST db field (case-insensitively). It is an alias, not a database: it never appears in SHOW DATABASES, CREATE DATABASE neo4j is an idempotent no-op on default, and DROP DATABASE neo4j is refused exactly as dropping default is.
  • 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.

An even voter set is strictly worse than the odd set one smaller: 4 voters tolerate 1 failure — the same as 3 — but every election and write needs a quorum of 3 of the 4 alive, so the extra voter buys nothing and only adds another way to lose quorum. The extreme case is 2 voters: like 1 voter it tolerates zero failures, yet the cluster dies when either node fails. POST /mgmt/init and POST /mgmt/change-membership therefore refuse an even voter set by default with a 409 explaining the math:

{
  "error": "refusing an even voter set of 2: …",
  "hint": "pass \"allow_even\": true to force"
}

If you genuinely want an even set (a throwaway test cluster, say), wrap the member list and opt in explicitly:

curl -u neo4j:secret -X POST 127.0.0.1:21001/mgmt/change-membership \
  -H 'content-type: application/json' \
  -d '{"members": [1,2], "allow_even": true}'

(The same {"members": {…}, "allow_even": true} wrapper works for /mgmt/init, whose bare form is an {id: addr} map.)

Single-statement write-size limit

A replicated write crosses the peer wire inside one Raft log entry, and every follower’s /raft/append receiver bounds that body (PATINADB_RAFT_MAX_BODY_BYTES, default 64 MiB). An entry over the cap is not “slow” — it is not replicable at all. To keep that from ever reaching the wire, the leader measures every proposal against a single-entry limit (PATINADB_MAX_ENTRY_BYTES, default 48 MiB) and rejects an oversized statement up front with a non-retryable client error (Neo.ClientError.Statement.ArgumentError on Bolt, HTTP 400 on REST) that names the limit. Retrying the identical statement can never succeed — split the write into smaller batches instead:

  • Cypher: CALL { … } IN TRANSACTIONS OF <n> ROWS,
  • driver-side: smaller UNWIND batches per statement,
  • bulk loading: the chunk_rows option of the bulk-upsert endpoints.

Both knobs are documented under Configuration. If you raise them for a bulk workload, set PATINADB_RAFT_MAX_BODY_BYTES identically on every node and keep PATINADB_MAX_ENTRY_BYTES comfortably below it.

Raise both, never only the entry cap. The two are an ordered pair: above the receiver’s body cap the proposer-side limit cannot protect anything — every entry in the gap is proposed and then rejected by every follower with 413, forever, which is the exact cluster-wide wedge this limit exists to prevent. patinaDB now enforces the ordering for you: --check-config fails on a misordered pair, and a node that boots with one logs a loud warning and clamps its effective entry cap down to the body cap (it does not refuse to start, so upgrading a cluster that already runs a misordered pair is never an outage). Nothing node-local can detect a body cap raised on some nodes only — that part is still on you.

Batched appends

That single-entry limit does not bound a batch. When a follower falls behind — it was restarted, it is a fresh learner catching up, or a write burst outran replication — Raft ships up to 300 pending entries in one append body, and a batch of individually-legal entries can still exceed the receiver cap. Nothing about that is your fault or your configuration’s, so patinaDB handles it for you: the sender byte-budgets every append body against PATINADB_RAFT_MAX_BODY_BYTES and splits an oversized batch into budget-sized bodies, at entry granularity, delivered in order. You do not need to configure anything, and there is no visible effect beyond the catch-up taking a few more round trips. patinadb_raft_append_chunked_batches_total counts how often the oversized-batch path was entered.

After leader changes, a follower can already hold entries whose matching position the new leader has not yet confirmed. Before sending an oversized body, the sender may try up to two ordinary, authenticated empty AppendEntries requests at exact LogIds from that batch. A successful Raft log-match response proves only that prefix; its payload need not be resent. A conflicting or unavailable probe falls back to ordinary replication, and a higher vote still fences the sender. The probes share the original RPC time budget, not an extra unbounded timeout. Small appends are unchanged. Counters patinadb_raft_append_prefix_probes_total{target} and patinadb_raft_append_prefix_proven_entries_total{target} distinguish attempted checks from confirmed entries. These counters are not throughput or readiness guarantees; continue monitoring applied positions and follower lag.

One caveat if your cluster runs on a slow interconnect: a chunk still has to cross the wire inside Raft’s 250 ms RPC deadline, and a 56 MiB chunk (the budget at the default 64 MiB cap) needs roughly 1.9 Gbps of sustained goodput to make that. On a 1 Gbps link, lower PATINADB_RAFT_MAX_BODY_BYTES — it sets the receiver cap and the sender’s chunk budget together — until a full chunk transfers comfortably within 250 ms; 16 MiB is a reasonable starting point for 1 Gbps. This only affects catch-up after real lag; steady-state replication sends far smaller batches and never chunks.

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 --cluster-secret cluster-s3cr3t

# 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 --cluster-secret cluster-s3cr3t
patinadb-raft --id 3 --addr 127.0.0.1:21003 --db ./n3 \
  --bolt-addr 127.0.0.1:7689 --auth-password secret --cluster-secret cluster-s3cr3t

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]'

Cluster secret is mandatory for joining nodes. Peer /raft/* RPCs authenticate with a dedicated --cluster-secret (PATINADB_CLUSTER_SECRET) carried in the X-Cluster-Secret header — not with the Basic credentials a client uses. All nodes must share the same value. If it is left unset it falls back to --auth-password, but a clustered (non-bootstrap) node with auth enabled refuses to start without a dedicated --cluster-secret — so nodes 2 and 3 above (and every --join node below) need it set before first launch. A single --bootstrap node may start without it (it has no peers) but logs a warning; set it before adding peers. You may set it to the same value as the admin password to keep the effective secret unchanged while decoupling the two, so they can rotate independently (see Production Deployment → Internal cluster traffic). The --auth-password still has to match across nodes for operator /mgmt/* calls like the ones below.

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. All three need the same
# --cluster-secret (see the note above) — a joining node refuses to start without it.
patinadb-raft --id 1 --addr 127.0.0.1:21001 --db ./n1 --bootstrap --cluster-secret cluster-s3cr3t ...
patinadb-raft --id 2 --addr 127.0.0.1:21002 --db ./n2 --join 127.0.0.1:21001 --cluster-secret cluster-s3cr3t ...
patinadb-raft --id 3 --addr 127.0.0.1:21003 --db ./n3 --join 127.0.0.1:21001 --cluster-secret cluster-s3cr3t ...

--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.

A healing partition can briefly cost you the leader

Worth knowing before you see it in a graph and go looking for a fault that isn’t there.

patinaDB’s consensus layer does not implement pre-vote. The practical consequence: a voter that gets cut off from the cluster — a switch reload, a security-group change, a hypervisor pause — does not sit quietly. It notices the missing heartbeats, campaigns, and raises its term, even though it can never win while it is in the minority. When connectivity comes back, that higher term is what the rest of the cluster now sees, and the healthy, still-serving leader steps down and an election runs.

So the disruption arrives on heal, not on the partition — which is the counter-intuitive part. Nothing is lost: no committed write is affected, and the isolated node cannot have committed anything of its own. What you get is a brief write-unavailability blip, on the order of one election (~1–2 s), caused by a network recovering.

A leader lease (derived from the election timeout, roughly 2–3 s in the default configuration) suppresses this for short interruptions — a blip shorter than that window costs nothing. It delays the campaign; it does not prevent it. A partition that outlives the lease will produce the effect above.

What to do about it: treat it as normal. Your client should already be retrying writes — see the client retry contract — and a retry rides this out the same way it rides out an ordinary failover. It is not a reason to avoid healing a partition, and it is not a reason to page anyone. It is a reason not to interpret a leader change immediately after a network repair as a second fault: it is the first one finishing.

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.

Snapshot transfer: bounds and resume

When a follower or learner falls too far behind, the leader brings it up to date by streaming a whole snapshot to its POST /raft/snapshot receiver (an internal peer RPC, authenticated by the cluster secret — not a client endpoint). The transfer is file-backed and streamed in 64 KiB chunks on both sides (O(chunk) memory, no whole-graph blob), and hardened against the failure modes that matter at scale:

  • Interrupted transfers resume mid-stream (issue #607). The receiver keeps the partial receive file; a retrying sender first probes (x-patinadb-snapshot-probe → {"received": N}) and then streams only the remaining bytes from offset N. A length mismatch answers 409 and the sender restarts from byte 0 (once). A pre-#607 peer never answers the probe, so a mixed-version cluster degrades to the old from-scratch behavior.
  • Receive-side bounds (issue #605, per-attempt semantics since #1246). PATINADB_MAX_SNAPSHOT_BYTES (env-only, default 8 GiB, 0 = unlimited) caps a single upload attempt — over the cap the receiver aborts that attempt with 413 and KEEPS the partial file, so the probe/resume chain above continues from that offset and a snapshot larger than the cap assembles over several attempts. (Before #1246 the cap counted cumulatively and the abort deleted the partial: every retry restarted at byte 0 and died at the same offset — a snapshot past the cap could never install. Measured in the #1145 rejoin drill on a 71 GiB graph: 166 aborts, ~1.45 TB retransmitted, the follower never caught up.) A sender-declared total size that exceeds the destination filesystem’s free space is rejected with 507 before a byte is written, and the snapshot header prefix is capped at 4 MiB. The whole transfer is additionally bounded by install_snapshot_timeout (300 s, applied as a hard TTL, issue #592).

Tests: tests/snapshot_transfer_limits.rs (probe/resume, over-cap 413 with the partial kept, a larger-than-cap snapshot installing over the resume chain, insufficient-disk 507) and store.rs::decode_snapshot_header_rejects_prefix_above_the_hard_cap.

A slow (e.g. WAN-linked) learner can structurally never catch up (#1068)

The bullet list above resumes an INTERRUPTED transfer without losing progress — that is not the risk this section is about. The actual race is between two independently-sized things: how OFTEN the leader starts a new snapshot build, and how LONG one transfer takes to reach a given learner.

network.rs’s sender drops an in-flight transfer the moment a newer snapshot supersedes the one it is sending (the leader always serves its current snapshot, never a stale one). If the leader’s snapshot-build trigger fires again before a slow learner finishes downloading the current snapshot, that transfer is aborted out from under it and restarted against the newer id — the #607 resume above only helps a retry of the SAME snapshot id, not this case. If the transfer reliably takes longer than the trigger interval, this repeats forever: the learner is not simply slow, it can never finish, for as long as the write rate holds.

Measured starting point for this issue (10 sustained writes/s, the openraft default snapshot_logs_since_last of 5000): the leader triggers a new snapshot build every 5000 ÷ 10 = 500 s (~8.3 min). A snapshot transfer observed on a real WAN link for the deployment this issue was raised against took 13–65 min. 65 min (or even the low end, 13 min) is longer than the 8.3-minute trigger interval, so under those conditions a learner behind that link cannot complete a catch-up at all — not probabilistically, structurally. Change any one of those three numbers (a slower write rate, a wider trigger threshold, a faster link) and the same arithmetic can give the opposite answer; there is nothing universally broken about snapshot transfer, only this specific combination of rate/threshold/bandwidth.

What to do about it today (the structural fix — making the snapshot trigger aware of a slow transfer already in flight — is very likely an openraft-level change and is tracked separately, not shipped as part of #1068):

  • Raise PATINADB_SNAPSHOT_LOGS_SINCE_LAST (issue #1099) so the trigger interval comfortably exceeds your worst observed transfer time. This trades a longer Raft log (slower catch-up start, more log disk) for a wider window a transfer can complete inside.
  • Improve the link (shortens the transfer) or reduce the sustained write rate a learner must survive a full catch-up under.
  • Check the arithmetic yourself before deploying across a WAN link, don’t guess: set both PATINADB_RAFT_EXPECTED_WRITE_RATE_PER_SEC (your sustained write rate, writes/s) and PATINADB_RAFT_EXPECTED_SNAPSHOT_TRANSFER_SECS (your own measured or estimated whole-transfer time on the slowest WAN link in the deployment — roughly snapshot size ÷ link bandwidth) and run --check-config. It reports the trigger interval implied by your current PATINADB_SNAPSHOT_LOGS_SINCE_LAST next to your declared transfer time and flags the combination as a problem when the transfer cannot structurally finish. Both env vars are opt-in declarations — patinaDB cannot measure either at boot — so leaving them unset changes nothing about a node’s behaviour or its --check-config output.

The per-attempt install_snapshot_timeout (300 s default, PATINADB_RAFT_INSTALL_SNAPSHOT_TIMEOUT_MS, issue #1066) is a different knob: since issue #1068 the streamed upload itself is bounded by PROGRESS (PATINADB_RAFT_SNAPSHOT_PROGRESS_TIMEOUT_MS, 30 s default — the transfer is aborted only after that long with zero bytes flowing, so its total duration is unbounded on any link that keeps delivering), and install_snapshot_timeout covers only the resume probe and the post-upload install/response wait. Neither knob helps or hurts this race — the failure mode here is a newer snapshot superseding the old one, not a request expiring.

Gate: app::wan_snapshot_budget::tests pins the calculation itself (not a specific number) — it reproduces this section’s own measured scenario (10 writes/s, threshold 5000, a 13–65 min transfer) and fails if the defaults ever drift into a regime where that scenario reads as feasible, plus a counter-proof that the same formula reads a slower workload or a raised threshold as feasible.

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)
READOn the leader: caught-up non-leader members, including learners. On a follower/learner: eligible self only, not unknown peers. Leader fallback applies when no eligible replica remains; READ can be empty when stale self has no known leader address.
ROUTEall members (any node can answer a routing request)

The follower-side self-exclusion measures an apply backlog (last_log_index − last_applied on the node itself), not distance from the leader’s log. A bandwidth-limited WAN learner — log delivery slow, apply fast — may accumulate no local backlog. The optional --max-leader-contact-age-secs gate additionally requires recent leader contact and bounds advertised leader-commit versus local-applied lag. Configure it explicitly; it is disabled by default. Routing still does not replace causal bookmarks or a linearizable read mode. See Multi-Region Topology.

Running nodes across regions? See Multi-Region Topology for opt-in regional tiers and scoped bookmarks. Regional refresh is coordinated by the leader; the legacy table described here has no region preference.

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). A follower is never advertised as a writer merely because the leader’s Bolt address has not been discovered yet. A plain routing request can probe the known leader on demand, sharing one discovery operation per node and bounded by the existing two-second version-probe budget. If the leader or its address remains unavailable, WRITE is empty and that incomplete table has a one-second TTL rather than the normal 300 seconds. Eligible READ and ROUTE entries remain available. Drivers must refresh/retry when no writer is available; retain bookmarks and use the documented write-token contract to reconcile unknown outcomes, not blind mutation 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 with ?min_index=<log_index of a prior write>Read-your-writes on any node (followers included) — bounded wait, no round-trip. See REST API → Read-your-writes over REST.
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.

A node that is still backfilling its engram history

A node that joins or rejoins a cluster receives the current state first and its engram history — the timeline behind AS OF, AS OF TAG and diff — afterwards, in the background. In between it is a fully useful cluster member: it holds quorum, applies writes, and answers reads at the current state. What it cannot do is answer a time-travel read that reaches further back than it has got, and it will not pretend otherwise.

Voting is not election eligibility. Such a member may vote, but cannot become leader until every local database has complete history. Before relying on a replacement voter for failover, check /version reports history_incomplete: false; current-state row counts, membership and apply-lag checks are insufficient. Do not disable this safety rule to make a failover test pass.

How to see it. /ready reports the condition as its own field, and the node stays ready: true while it does:

{ "ready": true, "history": "history_backfilling", "current_leader": 2, "apply_lag": 0 }

There are three values, and the difference between the first two matters more than anything else in this section:

historyMeaningWhat to do
(absent)Complete history. The normal state.Nothing.
history_backfillingCatching up. Ends on its own.Watch it. If it does not end, see below.
history_backfill_fencedThe backfill hit an integrity mismatch and will never complete on its own.Inspect the logged reason and source history before planning recovery; re-seeding the same unsupported history can repeat the fence.
history_state_unreadableThe node could not read its own history state.Investigate the node’s storage; it is treated as incomplete.

The middle two look similar and are opposites. A history_backfilling node is making progress; a history_backfill_fenced node is not and never will be, and an operator who reads it as “still catching up” waits forever.

The #1261 cluster experiment encounters this with an old bulk-benchmark fixture seeded below the normal Engram write path: both replacement voters have current data but fenced history. This is not a fixture of ordinary Raft write history. The experimental root-snapshot history carrier is being qualified separately; it must not be assumed enabled or qualified by a current-state bootstrap succeeding.

How long it takes, and how to slow it down. The backfill pulls the history back in segments, each its own short request whose result is saved before the next one starts. That means it can be throttled or paused at any point without losing progress and without any risk of it having to start over:

PATINADB_BACKFILL_MAX_BYTES_PER_SEC=1048576   # unset (default) = unthrottled

Set it if the node is behind a link you also need for other traffic.

Do you need to? On a LAN, measured: no. A backfill running unthrottled alongside a snapshot transfer changed that transfer’s wall time by 0.99x-1.02x over four runs — i.e. not at all — while 166-207 backfill segments were verifiably in flight during the measurement window (#1145, 2026-08-30; loopback, --release, 200 engrams of history, a shared 16-core box at loadavg 14-16; harness scripts/backfill-contention-rehearsal.sh).

That result does not carry to a WAN link, and we do not claim it does. Both legs ran over loopback, so what was measured is CPU, disk and lock contention between the two transfers — not shared bandwidth. If your backfilling node sits behind a rate-limited link that a snapshot transfer also needs, throttle it; that case is unmeasured.

Three things change while any member is in this state, all of them deliberate and all of them temporary:

  1. That node will not become leader. Only a node with a complete history can be leader, because the leader is what resolves restore, fork and diff and then replicates the answer. The node still votes and still replicates — it simply does not stand for election. In the rare case where every remaining node is backfilling, the cluster deliberately holds no election rather than electing a node that would answer those commands from a history it does not have.
  2. History retention is paused for that database. A squash rewrites history into a genesis whose body is the state at the cutoff, which a backfilling node cannot produce. The leader defers it and logs history retention: deferred — a cluster member is backfilling the engram history of THIS database. The same deferral applies to CREATE TAG … AS OF and FORK … AS OF pointing anywhere below HEAD; tagging and forking at HEAD are unaffected, and so are your other databases — the deferral is scoped to the database that is actually catching up. If a backfill never finishes, retention for that database never runs — so history grows for a reason you can see in the log, rather than silently.
  3. GET /mgmt/snapshot?history=true is refused on such a node (409), because the archive it produced would be silently short. A plain HEAD-state backup (without ?history=true) is unaffected — it claims no timeline.

Telling “catching up” from “never will”. Two metrics separate them, and this is the distinction that used to be impossible to make from outside:

MetricMeaning
patinadb_history_backfill_bytes_received_totalRising = the backfill is making progress.
patinadb_history_backfill_fenced1 = it will not finish without an operator.
patinadb_raft_snapshot_transfer_in_progress{target}1 on the sender while a snapshot stream to target is running, with _started_timestamp_seconds giving its age and patinadb_raft_snapshot_bytes_sent_total{target} its progress.
patinadb_raft_snapshot_receive_in_progress1 on the receiver (the lagging node itself) while a snapshot install streams in, with _started_timestamp_seconds and patinadb_raft_snapshot_bytes_received_total alongside.
patinadb_raft_snapshot_transfers_aborted_total{target}Rising = in-flight transfers to target were cancelled because a newer snapshot superseded them — the mid-transfer half of the livelock signal.
patinadb_snapshot_transfers_discarded_totalRising = snapshot transfers are being superseded before they finish. A node in that state can never catch up, however long you wait — see “A slow (e.g. WAN-linked) learner” above. A node that is merely behind leaves this at zero.
patinadb_raft_snapshot_forced_install_totalRising = a received snapshot was installed even though the receiver’s log position already covered it, because the receiver’s store did not contain the snapshot’s graph anchor or the chain’s depth below it (issue #1244). That is the offline-import/restore divergence class healing itself: the graph bypassed the Raft log on the seeding node, so a joiner was log-current and graph-missing at once. On a cluster whose data all came through the log this stays at zero; anything above zero means a node was caught diverged and repaired.

The last two are the ones worth an alert (the shipped alerts.yml covers them as PatinaDBSnapshotTransferDiscarded). Without them a livelocking rejoin and an ordinary slow one look identical, which is why that failure had to be found by arithmetic rather than by looking at a dashboard.

What your clients see is documented in Client retry contract — a re-route to the leader, which a contract-following client already handles, and which carries "reason": "history_below_local_floor" so you can tell it apart from an ordinary failover in a log.

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 (postcard, a non-self-describing codec), 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.

The mixed-version window pauses the newest safety checks — finish upgrades promptly

Ordinary writes never block during an upgrade — that is deliberate. To keep it that way, the proposer falls back to older wire shapes while any member is below the current protocol, and that fallback quietly pauses the newest apply-time safety checks for the duration:

  • below v9: the label-membership conflict check (concurrent SET n:X / SET n:Y against the same base can silently lose one update again);
  • below v10: the constraint-catalog check (a write racing a CREATE CONSTRAINT across a leader change can land unchecked again).

This is a bounded-time trade-off: acceptable for the minutes a normal rolling upgrade takes, not for a stalled one (one member never upgraded = those checks stay off forever). The window is therefore measured and loud:

  • gauge patinadb_cluster_min_protocol_version — the confirmed cluster minimum (0 = some member not yet confirmed);
  • gauge patinadb_mixed_version_window_seconds — how long the cluster has been below current (0 = fully current);
  • a WARN in the log once the window exceeds 60 s, repeated every 5 minutes while it persists, and an INFO when it closes.

Alert on patinadb_mixed_version_window_seconds exceeding your expected upgrade duration. The node deliberately never escalates beyond observability (no write refusal on a long window) — whether a stalled upgrade should eventually gate anything is an operator policy decision, not one the node makes implicitly.

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

Expect a short write blip when the leader restarts

openraft 0.9 has no leadership transfer: a planned leader restart cannot hand off first, so the remaining voters only notice the missing heartbeats and elect a new leader after the configured election timeout — 750–1500 ms (heartbeat_interval 250 ms; app.rs election_bounds). SIGTERM is handled: the HTTP server drains in-flight requests and the Raft state flushes cleanly, so the blip is a write-availability gap, not data loss — writes that arrive during the gap fail fast as retryable, and a client following the retry contract (retry writes for at least ~10 s — measured: a full-cluster cold start refuses writes for ~3.5 s, see scripts/deployment-rehearsal.sh) rides through it without an error surfacing. Reads on the surviving followers keep working (subject to their freshness).

Recommended order for a 3-node cluster — followers first, leader last:

  1. Pick a follower (GET /mgmt/cluster on any node shows members[].is_leader and current_leader; GET /health on the node itself also reports current_leader).
  2. Restart it onto the new version. Wait until it is back and caught up before touching the next node:
    • GET /ready returns 200 (not 503 with reason: "no_leader" / "installing" / "lagging");
    • GET /mgmt/cluster shows it in members with a last_applied index that has converged with the leader’s;
    • GET /version shows the new protocol_version (the capability gate keeps commands the mixed cluster can’t yet apply from being proposed — mixed-version operation is safe, see above).
  3. Repeat for the second follower.
  4. Restart the leader last. Expect the 750–1500 ms re-election blip here, plus the new leader’s startup; verify the same way (/mgmt/cluster shows a new current_leader, /ready 200 on every node, protocol_version equal everywhere).

Because of the protocol-version capability gate, a mixed-version cluster in the middle of this procedure is safe: nothing is proposed that an un-upgraded member cannot apply, so there is no order dependency to get wrong — the leader-last ordering only minimises the number of re-election blips to one.

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.

Multi-Region Topology

patinaDB supports serving reads from a region close to your users while keeping writes in a single, authoritative region — one Raft cluster, every voter in the write region, every remote region running as learners. This chapter describes that topology (RFC-0023), what it guarantees, what it costs, and opt-in regional routing with scoped causal bookmarks (RFC-0027/0028).

This is a topology decision, not an operating procedure — see Production Deployment for the day-to-day runbook (rotation, backup, bind & expose) that applies regardless of how many regions you run.

The shape: one cluster, one write region, N read regions

   Write region (e.g. eu-west)              Read region (e.g. us-east)
  ┌─────────────────────────────┐          ┌──────────────────────────┐
  │  node 1 (voter)              │          │  node 4 (learner)        │
  │  node 2 (voter)   <───Raft───┼──────────┼─→ node 5 (learner)       │
  │  node 3 (voter)              │  WAN     │  node 6 (learner)        │
  └─────────────────────────────┘          └──────────────────────────┘
        ▲ writes                                  ▲ reads (local)
        │                                          │
   backend, EU traffic                     backend, US traffic
  • Voters live only in the write region. They form the Raft quorum and elect the leader. The current leader coordinates writes; being a voter does not make every member an independent writer. See High Availability → Cluster sizing for how many you need there.
  • Every remote region runs as learners (High Availability → Learners as read replicas): they replicate the log and apply it locally, exactly like an in-region learner, just over a longer link. Learners never vote and never become leader — growing or shrinking a read region never touches quorum size or failover behavior in the write region.
  • This is one cluster, not N clusters. There is a single Raft log, a single monotonic index, a single set of causal bookmarks. A write in the EU region and a bookmarked read in the US region compose correctly today, because there is only one index space to compose against — see High Availability → Read consistency for the bookmark mechanism itself.

Node roles: membership is not a region label

All nodes run the same server binary. There is no separate reader executable or node_type: reader setting: Raft membership determines voting rights.

RoleVotes / counts toward quorum?Can become leader?Purpose
Leader (a voter)YesAlready leaderCoordinates writes and can serve reads
Voting followerYesYes, through electionHA member; replicates data and can serve eligible reads
Learner (nonvoting replica)NoNo, unless explicitly promoted firstReplicates data and serves eligible reads without expanding quorum

“Follower” is often used informally for any replica. For a remote reader that must not vote, configure a learner, not another voting follower. node_name is a display label and node_region selects routing locality; neither grants voting rights nor restricts which voter can win an election. There is no permanently assigned writer among the three voters.

Setup recipe: three EU voters and remote learners

Example inventory (names and region labels are operator choices):

IDNameRegionFinal membership
1eu-primary-aeu-westVoter
2eu-primary-beu-westVoter
3eu-primary-ceu-westVoter
4us-reader-aus-eastLearner
5ap-reader-aap-southeastLearner

Place the three voters in independent failure domains within the main region, not on one host. A three-voter majority is two: one voter may fail without losing write quorum. Adding remote learners does not change that arithmetic. One learner per read region is not local read HA; add another independent learner there if local-reader failure must be tolerated without remote fallback. Every replica holds the data, not just tickets associated with its region.

  1. Configure authentication, private peer connectivity, verified HTTP/Bolt TLS, the shared cluster secret and suitable license entitlements on every node. Give each node a unique numeric ID, its own data directory, and a client-reachable advertised Bolt address. Use the complete production setup, not the fragment below alone.

  2. Bootstrap only node 1 with --bootstrap. Start nodes 2 and 3 with --join <existing-member-http-address>; they initially join as learners.

  3. Check catch-up/readiness and peer-version confirmation, then call POST /mgmt/change-membership on the leader with JSON [1,2,3]. This is the complete desired voter set, not an incremental list. The membership procedure shows the calls.

  4. Start nodes 4 and 5 using --join, without --bootstrap. Leave them as learners: do not add their IDs to the voter set. Joining and catching up do not automatically promote them. Never bootstrap a separate cluster in a read region and expect it to join this replication stream.

  5. Enable regional routing and scoped bookmarks on the upgraded members. This YAML fragment is for node 4; change name/region for each other node:

    node_name: us-reader-a
    node_region: us-east
    regional_routing_enabled: true
    causal_bookmark_mode: compatible
    bookmark_wait_timeout_secs: 10
    

    These are routing settings, not the membership declaration. The timeout is a starting policy, not a measured WAN latency promise. Configure the leader-contact freshness guard and routing coordination budgets for your actual links as described below. Validate each complete configuration with patinadb-raft --config /path/to/node.yaml --check-config before starting it.

  6. Admit a learner to client traffic only when current-state readiness and a bookmarked read pass. History backfill can finish separately; current-state readiness is not proof that historical queries are ready. Verify that the voter membership remains exactly [1,2,3] after adding remote readers.

Use the regional driver policy below for each backend deployment. Losing the entire EU voting majority stops writes; remote learners are not an automatic disaster-recovery quorum, regardless of how many remain online.

Why one cluster instead of one cluster per region

The alternative — an independent cluster per region, kept in sync by some replication layer — was evaluated and rejected (RFC-0023 §3.3): patinaDB has no applier that can play a foreign engram stream into a second cluster today, and a design that replicates across cluster boundaries reopens exactly the read-your-writes hazard this topology is built to avoid (bookmarks and the CDC resume cursor are correct within one cluster’s index space; scoped causal bookmarks reject another domain, but do not replicate between clusters). A stretched-quorum design (voters split across regions) was also evaluated and rejected for now: it needs Raft timing headroom that must be measured and configured for the actual links, and a leader that fails over into the wrong region would pay a WAN round-trip on every write until it fails over again. Learners-in-remote-regions has neither problem, at the cost described below.

Operating it: a routing driver per regional policy

Enable regional_routing_enabled: true, causal_bookmark_mode: compatible (or strict after legacy clients have migrated), and a positive bookmark_wait_timeout_secs on the upgraded nodes. Set node_region and optionally node_name per node; numeric Raft IDs remain the membership identity.

A US backend can use one routing driver seeded at an upgraded local learner:

neo4j+s://us-reader.example:7687?region=us-east&policy=prefer-local

Use a driver resolver or appropriate seed DNS to reach another upgraded router when the initial seed is unavailable. A routing driver manages multiple underlying Bolt connections; an individual Bolt connection still terminates at one server. Keep region/policy constant per driver instance, not per transaction.

Mark reads as reads: use session.execute_read(...), or a session/transaction with READ_ACCESS. Ordinary session.run(...) defaults to write access in the Python driver and can therefore reach the leader even for a read-only Cypher statement. A regional URI does not override the driver’s chosen access mode.

For the official Python driver 6.3.0, explicitly select the database in each session, for example driver.session(database="default", bookmarks=bookmarks). This is the supported learned-router seed-failover path exercised by tests/bolt-driver/test_regional_routing.py: the same driver survives its original seed being killed, using discovered routers rather than a replacement seed supplied by the test.

An unspecified database has a different discovery requirement in that driver. With patinaDB’s server-side routing (SSR) capability disabled, each new unnamed session resolves its home database through the original URI seed. The routing table cached for the returned default database does not supply the routers for that unnamed lookup; increasing the table TTL does not fix this. If intentionally using unnamed sessions, supply a resolver or seed DNS that still yields a live router. The driver gate separately exercises failure with a dead sole seed and recovery through a resolver yielding another local learner. patinaDB does not advertise unimplemented SSR support to bypass this distinction. These are local driver-contract checks, not WAN qualification.

Use managed read transactions (session.execute_read(...)) when relying on the driver to retry a connection lost during failover. Raw autocommit session.run(...) and manually opened transactions do not automatically replay failed work. The driver gate uses managed reads for failure/recovery legs, preserving bookmarks and the same driver instance; its autocommit and explicit-BEGIN causal checks remain unmanaged so their timeout and invalid-bookmark errors are observable.

  • WRITE targets the current leader in the write region.
  • READ, prefer-local: the first nonempty eligible tier wins: local nonleaders, local leader, remote nonleaders, remote leader. Tiers are not mixed, because address ordering does not make a standard driver prefer the first region.
  • READ, local-only: only the first two tiers are allowed. No eligible local reader means a retryable routing failure, not silent remote fallback.
  • ROUTE lists capable current members. A learner forwards regional table computation to the known leader with bounded private coordination.
  • No region context: legacy nonregional selection remains unchanged.
  • Direct bolt+s://: still selects one endpoint, not automatic discovery.

Profile probes share a bounded coordination budget across concurrency-limited waves, so a stalled earlier member does not monopolize discovery of later readers. Overload or insufficient deadlines can still exclude healthy peers; tune the coordination deadline for the actual network, not loopback timings.

With local-only, losing every local reader can also prevent a standard driver from acquiring a write connection: the driver expects a usable routing table, including readers. Use a separate policy/driver for a write-availability escape hatch; local-only is not a promise to allow cross-region reads during failure.

For a backend that must retain writes during a regional reader outage, use a global neo4j+s:// driver for writes and a separate regional local-only driver for reads. Pass the opaque bookmarks between their sessions explicitly. Do not replay an unknown write through the other pool. The docs-only application suite exercises this separation in scripts/customer-blackbox (#1261).

Configure redundant bootstrap addresses too: learned routers can be discarded after failed discovery, leaving a driver dependent on its original seed again. If using a custom resolver with a symbolic seed alias, expand only that alias to trusted router endpoints. Pass discovered physical addresses through unchanged; rewriting a selected regional reader to arbitrary voters breaks the routing policy.

Carry the acknowledged write’s bookmarks into the next read. A selected reader waits for its applied prefix before opening the read snapshot, or returns a bounded retryable error. The driver is not promised to switch automatically to the writer. An explicit writer fallback starts a new transaction with the same bookmarks and remaining request deadline; it must respect the selected regional policy.

What IS guaranteed: read-your-writes, everywhere, via bookmarks

A single Raft index space means the causal-bookmark mechanism (High Availability → Read consistency) composes correctly across regions with no extra code: write in the EU region, carry the bookmark to the US region, read there — the local learner waits (bounded, by the honest cost of replication lag) until its own applied index reaches the bookmarked one, then serves the read. This is read-your-writes by construction, not a special multi-region feature — it is the same mechanism a single-region deployment already relies on, and it is the reason this topology was chosen over the alternatives (RFC-0023 §3.1): the single-backend client that reads back its own writes is the sharpest detector of a read-your-writes violation, and this topology passes it without new machinery.

Legacy freshness filtering and its limits

Regional routing obtains fresh authenticated profiles on the leader and filters applied progress relative to its own frontier. A changed membership invalidates the response. History backfill alone does not exclude a current-state-ready learner. Profiles are not quorum leases; queries still enforce their bookmarks. Regional refresh requires contact with the write-region leader. The following describes legacy routing without a region context.

The routing table’s READ list drops a lagging replica when it is computed on the leader — the leader has a full per-follower replication view, and excludes anyone lagging beyond --readiness-max-lag (see Configuration Reference). But when the table is computed on a follower or learner — which, in this topology, is every node a remote-region client is likely to ask — there is no such view to filter with: openraft only populates per-peer replication metrics on the leader. The follower therefore omits unknown peers, rather than advertising them as fresh. A learner cannot infer sibling lag from this local metrics view (verified directly against the vendored openraft 0.9.24 source: RaftMetrics:: replication is Some(..) only on the leader).

What issue #1070 does fix: a learner always knows its own apply lag — the same signal /ready and the evict-voter guard already compute, against the same --readiness-max-lag knob — and now excludes itself from its own READ recommendation when it knows it is stale, rather than recommending a read target it can see, from its own state, is behind.

Apply backlog alone does not detect slow log delivery. Configure --max-leader-contact-age-secs for the additional fail-closed local freshness gate: it checks recent leader contact and the advertised leader commit index against the local applied index. Without this optional setting, that additional gate is disabled. See Configuration Reference. Neither local freshness nor routing makes an arbitrary read linearizable. Use causal bookmarks for read-your-writes; request the documented linearizable read mode when that stronger contract is needed.

It still cannot vouch for any other member. If you need a hard bound on how stale a read target can be, bolt:// direct pinning (above) to a specific node you monitor independently is the honest answer — the routing table’s filtering is a best-effort improvement on top of that, not a substitute for it.

What is NOT built: automatic write failover across regions

If the write region is unreachable, writes stop — learners never campaign for leadership, so there is no automatic promotion of a remote-region learner into a voter. Ordinary membership changes still require the existing quorum; after losing it, simply promoting a learner cannot repair it. Recover enough original voters, or use a tested disaster-recovery procedure to restore into a fresh cluster — see Day-2 Operations. This is the honest price of the topology (RFC-0023 §3.1): it buys read locality and a correct RYW story with comparatively little new machinery, at the cost of write availability being tied to one region’s health, same as a single-region deployment always was.

Linearizable reads are leader-only and REST-only — by design, not a gap

consistency: "linearizable" (see High Availability → Read consistency) only ever runs on the leader, and only over REST — there is no Bolt path and no proxying to a remote leader. Do not read this as a missing feature to work around. It is the property that makes this topology viable at all: every write is linearizable in the write region (that is what “leader” means), and every read region gets a bounded-wait, bookmark-consistent read instead of paying a WAN round-trip per query. A client in a read region that occasionally needs a genuinely linearizable read pays for it explicitly — one WAN hop, on the rare call that asks for it — rather than every read silently paying that cost, or every read silently NOT getting that guarantee. Keeping linearizable reads leader-only and REST-only is a deliberate constraint of this topology, not scope this project intends to expand.

Summary

PropertyThis topology
Write availabilityTied to the write region; no automatic cross-region failover
Read localityOpt-in regional routing, or explicit direct client pinning
Read-your-writes across regionsYes, automatically, via causal bookmarks (one Raft index space)
Server-side region-aware routingOpt-in prefer-local/local-only tiers with leader coordination
Stale-read protection for a self-answering learnerOptional max_leader_contact_age_secs guard rejects local reads when leader contact is old or the last advertised leader commit exceeds the configured apply-lag bound; it still cannot judge peers
Linearizable readsLeader-only, REST-only, by design
Disaster recoveryManual: recover the original voter quorum, or restore into a fresh cluster

See RFC-0023 for the full decision record, including the alternatives considered and why they were set aside.

Leader-contact freshness

Set max_leader_contact_age_secs (or --max-leader-contact-age-secs) on read replicas to reject local Bolt and HTTP reads after that many seconds without an accepted leader heartbeat. The default is disabled for compatibility. A node that currently believes it is leader is exempt, so this follower/learner guard is not a quorum lease or a linearizability guarantee; use REST consistency=linearizable when that stronger leader-read guarantee is required. The guard is an admission check before query execution. Explicit Bolt read transactions are checked before their repeatable-read snapshot is pinned and again when a statement runs.

Client Retry Contract

patinaDB is a replicated database with optimistic concurrency. That means a correct client must retry certain errors — not because the database is unreliable, but because “retry me” is a normal, expected outcome of two writers touching the same data at the same time, or of a leader changing mid-request.

A backend that does not implement this contract will surface routine concurrency and failover events to its users as hard errors. This is the single most common integration mistake, so this chapter is deliberately concrete.

The rule

Every error response carries a machine-readable retryable field. Key on it.

{
  "error": "write rejected: base state changed",
  "neo4j_code": "Neo.TransientError.General.TransientError",
  "retryable": true
}

Do not key on the HTTP status code, and never on the message text. The message is for humans and may change. The status is genuinely ambiguous — see the 409 trap below.

Some responses carry additional fields — leader_id, leader_addr, and reason. The first two are contract: they tell a client where to retry. reason is not. It names why this node could not serve the request, for your logs and your operators; it is free to gain new values, and a client that branches on it will eventually branch wrong. Log it, alert on it, never if reason == ….

resp = session.post(url, json=payload)
if resp.ok:
    return resp.json()

body = resp.json()
if body.get("retryable"):
    # back off and retry — see "How to retry" below
    ...
else:
    raise ApplicationError(body["error"], code=body["neo4j_code"])

Explicit refusal before a write is submitted

A non-chunked /mgmt/upsert, /mgmt/upsert-edges or /mgmt/upsert-graph, or an autocommit data write through /cypher, may answer HTTP 503 with code: "READ_PUBLICATION_IN_PROGRESS" and write_outcome: "not_submitted". This combination means native snapshot admission failed during private resolve or preparation, before submitting this request to Raft. It made no graph write; a refused upsert also created no pre-load tag. A client may report the refusal, or submit a new attempt with backoff within its own deadline.

Non-chunked bulk endpoints and pipelined autocommit data writes through /cypher can also return code: "WRITE_ADMISSION_TIMEOUT", neo4j_code: "Neo.TransientError.General.DatabaseUnavailable", retryable: true and write_outcome: "not_submitted" when bulk initial leader/applied admission or pipelined ordering/read-index admission expires before any proposal. These waits use the smaller of the remaining query budget and bookmark_wait_timeout_secs (default 10 seconds); a disabled budget is omitted. The original absolute query deadline is retained across resolution and OCC attempts. This does not bound or certify a mutation after proposal.

Bolt pipelined autocommit writes have no such receipt, so the bookmark-wait budget does not apply to them. Their ordering/read-index wait is bounded only by the remaining query timeout. When none is configured they wait as long as needed, for example behind a slow apply. When the query timeout expires first, they fail with Neo.TransientError.General.DatabaseUnavailable / “write admission timed out before proposal”, without this HTTP non-submission receipt. Configure client timeouts to exceed admission waiting plus transport overhead: a client that disconnects earlier still has an unknown outcome. Chunked loads receive no non-submission receipt, even at this initial barrier. The receipt concerns graph submission, not absence of all server activity: governance permits are acquired before the barrier and released on refusal, and the denied attempt is audited. No load marker or graph mutation is created by this refusal.

The field describes this request’s outcome; retryable still describes whether a new attempt can help. Both pipelined and serialized modes emit the receipt only at that pre-proposal boundary. Reads, schema/management commands, chunked loads (including CALL { ... } IN TRANSACTIONS) and errors after proposal do not gain this receipt. A chunked load may have committed earlier chunks before an error. Endpoint or query text alone never authorizes a retry.

A missing receipt, an HTTP503 alone, a socket error or a timeout does not prove that a write was refused. Retain the ambiguous outcome and use the documented idempotency/readback policy. Do not infer rollback from an absent value or blindly replay a mutation. No client or server retry is added by this field (#1483).

The 409 trap

409 CONFLICT is returned for three different situations — two of them opposite:

SituationretryableWhat it means
Apply-time conflict rejectiontrueA concurrent write landed after yours was resolved. Retrying works.
Schema constraints changed mid-writetrueSomeone ran CREATE/DROP CONSTRAINT on this database between the moment your write was resolved and the moment it applied. Constraints are enforced when a statement is resolved, so a write that straddles that change is refused rather than committed against a constraint it was never checked against — and since v10 this holds even across a leader change: an entry a deposed leader managed to commit behind the new leader’s constraint change is rejected at apply on every replica (never half-applied), and answers with this same retryable rejection. Retrying works — the retry resolves under the new constraints (and is then rejected with ConstraintValidationFailed if it genuinely violates one). Expect a short burst of these while a constraint is being added to a busy database. During a rolling upgrade with pre-v10 members, this apply-time backstop is paused (writes keep flowing; the resolve-time refusal above still applies) — finish the upgrade to restore it.
Capability gate — member CONFIRMED too oldfalseA cluster member has answered a recent version check and IS too old for this write (an edition/scale limit, or an unfinished rolling upgrade). Retrying can never work — on this or any other node — until the operator finishes the upgrade.
Capability gate — member NOT YET confirmedtrueA cluster member has not answered a version check yet (normally a brief join/promotion race). Retry with backoff. If an unknown member is already unreachable before it was observed, the fail-closed gate remains blocked until that member recovers and answers, or an operator safely removes it through the normal membership procedure after reviewing quorum.

A client that retries on a non-retryable 409 loops forever. A client that fails on a retryable 409 drops a perfectly recoverable write (or, for the “not yet confirmed” case, gives up on a race that was already over by the time it read the response). This is exactly why the retryable field exists — never infer it from the fact that the status is 409, and never from the message text, only two of these three cases share the SAME neo4j_code too (see the table below) — retryable is the only field that tells them apart in every case.

The two capability-gate rows apply identically to a control command, not just a Cypher write. CREATE DATABASE, CREATE USER, and every other administrative/DDL command go through the SAME capability gate as a graph write, and (as of issue #866) return the exact same 409 body shape and the exact same two neo4j_codes for the two situations above — a client does not need a separate code path for “did my write get gated” vs “did my CREATE DATABASE get gated”. Before #866 the control-command surface returned a bare, un-annotated 409 for this condition (no retryable, no neo4j_code at all) — worse than a wrong value, since a missing field cannot even be measured and disproven; it silently looks like a response that never made any retry promise.

The failover case (easy to get wrong)

Neo.ClientError.Cluster.NotALeader is classified as a client error by the Neo4j taxonomy, but it is retryable — a client that only checks for the Neo.TransientError. prefix will treat a routine leader election as fatal.

It is retryable by re-routing, not by repeating the request against the same node. The response tells you where to go:

{
  "error": "not the leader",
  "neo4j_code": "Neo.ClientError.Cluster.NotALeader",
  "retryable": true,
  "leader_id": 2,
  "leader_addr": "patinadb-node2.internal:21001"
}

Retry against leader_addr. If it is null, an election is in progress — back off and retry the whole discovery.

If you use the Bolt protocol with an official Neo4j driver and a neo4j:// URL, the driver’s routing table handles this for you. The REST API has no such layer: your client owns it.

The same response, for a different reason: an incomplete history

A node that has just joined (or rejoined) a cluster serves reads immediately, but its engram history — the timeline behind AS OF, AS OF TAG and diff — arrives afterwards, in the background. While that is still running, a time-travel read that reaches further back than the node has got answers with the same re-route response as above, plus "reason": "history_below_local_floor":

{
  "error": "engram 0e5b… is not in this node's local history, and this node's history is INCOMPLETE …",
  "neo4j_code": "Neo.ClientError.Cluster.NotALeader",
  "retryable": true,
  "leader_id": 2,
  "leader_addr": "patinadb-node2.internal:21001",
  "reason": "history_below_local_floor"
}

If you already implement the retry contract, you do not need to do anything — this is the response you already handle. It is listed here because two things about it are worth knowing before you hit it in production:

  • It is temporary and it is visible. The node reports the condition on /ready as a history field (history_backfilling) without going un-ready — it is serving, and pulling it out of a load balancer would turn a non-outage into one. If that field instead reads history_backfill_fenced, the node will not recover on its own and needs an operator; see High availability.
  • Ordinary reads and writes are unaffected. Only a time-travel read that reaches below what the node has so far is re-routed. A read at the current state is answered locally, as always.

The cost, stated plainly: with a Bolt driver, this evicts the node from your routing table. Official Neo4j drivers treat Neo.ClientError.Cluster.NotALeader as a routing error and drop the server from their table before retrying. So an application that runs AS OF queries in a loop against a node that is still backfilling will repeatedly lose that node from its read pool and re-fetch routing. It is self-correcting and it costs you no wrong answers — but if your workload is time-travel-heavy and you are rejoining nodes often, prefer pinning those queries to a node whose /ready shows no history field.

Do not parse the human-readable text. On some code paths the error / message fields carry raw internal routing text instead of a clean sentence — e.g. has to forward request to: Some(2), Some(BasicNode { addr: … }). That is the same not-the-leader condition in workmanlike dress. Branch on the machine-readable fields only — neo4j_code, retryable, leader_id, leader_addr — and treat error/message as log text, never as a contract.

What each code means

Retryable:

neo4j_codeCauseRetry strategy
…TransientError.General.TransientErrorWrite–write conflict (optimistic concurrency), an apply-time rejection, a schema-constraint change that straddled the write (see the 409 trap table above), a COMMIT that lost leadership mid-request, a cluster member whose protocol version is not confirmed yet (most visible right after a node joins/is promoted — see the 409 trap table above), or another generic transient condition (e.g. a per-node concurrency-limit shed)Re-read, re-resolve, retry. Do not blindly resend a stale computed value. For a normal version-confirmation race, a short retry is enough and the same write is accepted once the member answers. An unknown unreachable member is different: gated writes remain blocked until it recovers or an operator completes a quorum-safe membership change.
…TransientError.General.DatabaseUnavailableNode degraded or not currently servingBack off; try another node.
…TransientError.Transaction.TerminatedTransaction killed by an operator or by the idle reaperStart a new transaction.
…TransientError.Transaction.BookmarkTimeoutThis node has not yet caught up to your causal bookmark (Bolt) or min_index (REST)Retry (another replica may be caught up).
…ClientError.Cluster.NotALeaderThis node is not the leader or cannot serve this particular request and knows who can — including a time-travel read below this node’s own history (reason: history_below_local_floor, see above)Re-route to leader_addr.

Note (fixed): OCC write-write conflicts and a COMMIT losing leadership used to be surfaced as Neo.TransientError.Transaction.LockClientStopped. The official Neo4j drivers’ error-classification logic excludes that specific code from automatic retry by name (the Python driver’s error-rewrite map turns it into a non-retryable ClientError; the Java/JS 4.4-line drivers exclude it too) — so a routine cluster event surfaced as a hard application error under a driver’s managed-transaction function. Both cases now emit Neo.TransientError.General.TransientError, which every official driver variant retries. If you see the old code from an older patinaDB deployment, treat it exactly like the row above (re-read, re-resolve, retry) — the semantics never changed, only which code named them.

Not retryable — fix the request, not the timing:

neo4j_codeCause
…ClientError.Statement.SyntaxError / SemanticError / TypeError / ArgumentErrorThe query or its parameters are wrong — or (same code, different cause) the capability gate refused a write because a cluster member is CONFIRMED too old for it (unfinished rolling upgrade / edition limit; see the 409 trap table above). Either way retrying the identical request cannot succeed: fix the query, or finish upgrading the cluster.
…ClientError.Security.Unauthorized / ForbiddenCredentials or permissions.
…ClientError.Schema.ConstraintValidationFailedThe write violates a declared constraint.
…TransientError.Transaction.LockClientStoppedAn explicit transaction’s buffered-op cap was exceeded (--max-tx-ops / PATINADB_MAX_TX_OPS) — a deterministic resource limit, not a concurrency conflict. Retrying the identical statement set hits the same cap again; split the transaction into smaller units, or raise the cap.
…ClientError.Statement.ResourceExhaustedThe query exceeded a memory/scale bound. Add a LIMIT, or raise the bound.
…ClientError.Security.AuthenticationRateLimitToo many failed logins. (Waiting helps; retrying with the same bad credential does not.)

Writes are at-least-once

patinaDB’s write contract is at-least-once, not exactly-once. (An idempotency token narrows this to recognisably at-least-once for the writes that carry one — see that section; everything here stays true for any write that carries no token.) Inside the cluster, re-application of the same Raft log entry is deduplicated by the store — internal redelivery is safe. But a client-level retry is a new execution, not a redelivery: the statement is resolved against current state again, and CREATE mints fresh vertex UUIDs at resolve time (Uuid::new_v4 in the DML resolve path). So:

If you sent a write, the ack was lost (a 503, a timeout, a connection reset — anything where you did not see a definitive response), and the write had in fact committed, resending it applies it a second time. With CREATE, that means duplicate nodes/relationships that differ only in their UUIDs.

This is measured, not assumed (#959)

Until 2026-08 the paragraph above was an argument from how the system is built. It is now a measurement. Deterministic simulation testing (RFC-0019) gained the ability to lose a response after the request had already been delivered and taken effect — the “the write happened, the acknowledgement did not” case, which the simulator previously could not produce at all, because a fault was only ever decided before delivery.

Under that fault the ambiguous outcome is not a corner case. It is the normal one:

Measured 2026-08-11 (#959, crates/patinadb-raft/tests/dst_response_drop.rs). Conditions: 3-node cluster under the deterministic simulator, one node partitioned for the workload window, 55% of responses dropped after delivery, 6 single-shot writes per run with a 400 ms client budget, seeds 0..16.

The class occurred in 10 of 16 runs. In every one of those 10, the set of writes the client saw time out was identical to the set of writes that were nonetheless committed and present on every node afterwards (unacked == unacked-but-persisted). Under this fault, every single client timeout was a false negative: the write was already on a quorum when the client gave up on it.

Read the consequence carefully, because it is the whole reason the rules below are not optional. A client that blindly resends after a timeout is, in these runs, resending something that already committed — in 10 runs out of 16. Timeouts are not a rare unlucky path where duplicates might appear; they are a path where, in this fault regime, the duplicate is what you get unless the write was idempotent by construction. Constraints, MERGE, and the conditional-apply work (RFC-0022, preimage/CAS) are what stand between “the ack was lost” and “the data forked”.

The honest scope of the number: it characterises this fault regime (an aggressive response-loss schedule on a degraded cluster), not steady-state production, where most writes are acknowledged normally. It does not say “10/16 of your writes duplicate”. It says that when you do hit the ambiguous outcome, the write having succeeded is the overwhelmingly likely explanation — so “it timed out, so it probably didn’t apply” is exactly backwards.

Rules for living with this:

  • Declare UNIQUE / NODE KEY constraints on business keys. The duplicate retry then fails cleanly with Neo.ClientError.Schema.ConstraintValidationFailed instead of silently forking your data — the constraint turns “applied twice” into “loud error on the second attempt”.
  • Prefer MERGE over CREATE for anything with a business key; a retried MERGE matches the already-committed row and is naturally idempotent.
  • Retry-once discipline for ambiguous outcomes. A response with retryable: false (or a non-retryable Bolt failure) means the write definitively did not apply — safe to resend. No response at all is the ambiguous case: resend at most once, and only for writes that are idempotent by construction (constraint- or MERGE-guarded) — otherwise reconcile first (query for the row the write would have created).
  • Bolt: the same ambiguity surfaces to drivers as a Neo.TransientError.* failure (e.g. Neo.TransientError.General.TransientError on a write-write conflict or a lost-leadership COMMIT, or Neo.TransientError.Transaction. Terminated when an operator or the idle reaper killed the transaction), as Neo.ClientError.Cluster.NotALeader when the write reached a non-leader / a leaderless window, or as a plain connection error when the socket drops mid-commit. A driver executing a transaction function may auto-retry transient failures; the application owns idempotency — use constraints / MERGE there too.

Idempotency tokens

The rules above make a retry survivable. An idempotency token makes it recognisable: you attach a unique token to a write, and if that exact request already committed — even under a different leader — the retry applies nothing and tells you where the original landed.

Where the token goes. The token is wired end to end on both client surfaces:

  • REST: send it as the Idempotency-Key request header on POST /cypher (HTTP header names are case-insensitive). The write’s JSON response carries dedup_active and, when dedup fired, duplicate_of — see Two fields, two different questions.
  • Bolt: put it in the transaction metadata under the reserved key idempotency_key — in BEGIN’s tx_metadata for an explicit transaction (see below), or in the RUN’s own metadata for an autocommit statement.

A token must be printable ASCII (0x21–0x7E, no spaces or control characters), non-empty, and at most 128 bytes. An invalid token fails the request loudly with a client error — it is never silently treated as “no token”, so you can never believe a write was protected when it was not. Omitting the token means “no token”; explicitly supplying an empty header or empty token value is an error.

What it does, and what it is not

Transactions mean all-or-nothing; tokens prevent applying the same request twice across transaction attempts. Consider a lost commit acknowledgement:

Backend                         Database
   ---- COMMIT ------------------->
                                   commits durably
   <--- acknowledgement --- X connection lost

The backend cannot tell whether the commit succeeded. Closing the connection or sending an abort cannot undo an already committed transaction. Retrying “increment this counter” in a new transaction can therefore increment it twice, even though both transactions individually satisfy atomicity.

  • Confirmed rollback: the transaction did not commit; retry as appropriate.
  • Confirmed commit: the write succeeded.
  • Unknown commit outcome: retry the same logical request with the same token within its protection window, or use an inherently idempotent operation.

Generate the token once per logical request, outside any retried transaction callback. A new logical request needs a new token. The official Neo4j driver does not automatically generate Patina’s idempotency_key; the backend must supply and preserve it. Recovery across a backend restart also requires retaining the request identity. Bookmarks are separate: they make a subsequent reader wait for the committed write, not deduplicate another write.

The repository’s scripts/customer-blackbox/backend is an executable application example: its optional HTTP Idempotency-Key is scoped to a stable backend principal, with a durable request ledger committed in the same transaction as the ticket mutation. The ledger stores the canonical request fingerprint and original status/body; a conflicting reuse returns409. The backend reconciles against the writer before acknowledging an uncertain outcome, rather than trusting a speculative result produced before COMMIT. A replay may return a newer bookmark while preserving the original application result.

This application ledger is distinct from the database’s bounded native token cache. Its example has no automatic expiry: plan retention and storage before using that pattern in production. Preserve the backend’s stable principal IDs and shared secret across restarts; rotating them without migration breaks lookup of old request identities. Headerless example requests retain their original unknown-outcome behavior. See the example’s README and CONTRACT for configuration; this is not automatic indefinite deduplication of every database transaction.

A token answers a different question from everything else in this chapter. Constraints and MERGE make a duplicate application harmless. Conditional apply (RFC-0022) asks “is the value this write assumed still current?” — and a retry after a lost response passes that check, because nothing else changed the state; that is exactly what made the timeout ambiguous. A token asks the one question neither of those asks:

“Has this exact request already been committed?”

That is an existence check over request identity, independent of the data. It runs at apply time on every node, not on the leader, which is what makes it work in the case it exists for: after a failover your retry reaches a different node, and that node must reach the same conclusion.

Explicit transactions: the token goes on BEGIN, not each statement

session.execute_write(...) — the API official Neo4j drivers steer you toward — runs over an explicit transaction, not autocommit: one BEGIN, one or more RUNs, one COMMIT. Set the token in BEGIN’s tx_metadata (the same place a driver already sets provenance metadata via begin_transaction(metadata=…)); it is validated at BEGIN and applies to the whole transaction’s single Raft write at COMMIT. A RUN’s own tx_metadata inside an already-open explicit transaction is not read for this — the token is a property of the transaction, matching how the same message already carries provenance metadata. COMMIT’s success carries dedup_active/duplicate_of exactly like an autocommit write’s terminal response (issue #1136). An invalid token at BEGIN fails BEGIN itself, loudly, for the same reason an invalid token fails autocommit’s RUN: it must never silently become “no token” and let you believe the whole transaction was protected when BEGIN quietly ignored it.

Reading the dedup fields back over Bolt is the catch. For an explicit transaction — which is what the recommended driver API, session.execute_write(...), runs — the dedup_active/duplicate_of metadata rides on the COMMIT message’s SUCCESS, and the official Python driver does not expose that message’s metadata to your code: the ResultSummary you can reach comes from each statement’s RUN/PULL SUCCESS, and those statements were buffered, not committed, so they honestly report dedup_active: false. Over an explicit Bolt transaction the dedup fields are therefore not readable from application code — the protection is active (the token on BEGIN is honoured at COMMIT), but you cannot observe whether it fired. What you can rely on: a recognised duplicate commits as a success that changes nothing, so the driver’s auto-retry of a lost commit response is safe. If you need to observe the dedup decision — e.g. to distinguish “applied” from “already applied” — issue the write as a single autocommit statement (whose terminal SUCCESS you do get via ResultSummary) or over REST, where both fields are plain JSON response members.

The token contract — this part is yours, and we cannot check it

You choose the token, and it must be unique per logical write. A UUID per request is the obvious choice.

The server can neither enforce this nor detect a violation. If you use the same token for two genuinely different writes, the second is recognised as a duplicate of the first: it applies nothing, and you are told it succeeded. You lose that write, silently.

This is not an oversight we intend to close, and the reason is worth understanding, because it tells you how carefully to generate tokens. The obvious guard would be to bind the token to a fingerprint of the write itself and reject a mismatch. That is impossible here: a retried statement is re-resolved on the leader, and CREATE mints fresh UUIDs at resolve time (the same fact this chapter opened with), so a retry’s resolved write legitimately differs from the original’s. A fingerprint would make every honest CREATE retry look like a different request and dedup nothing at all.

So: derive the token from your notion of the request — an inbound request id, a job id, a (business-key, operation) pair — and never from a value that changes between attempts (a timestamp, a random draw taken per attempt, a retry counter).

The guarantee is a capacity, not a promise

A token is honoured for a bounded window. Read this as a capacity:

A token is honoured for 10 minutes of cluster time, or until 100 000 newer writes have landed in the same database — whichever comes first.

Both bounds are deliberately far outside a real retry. The measured cold-start window after a total cluster outage is ~4.3 s at worst (#1150), and this chapter’s own advice is to retry for at least ~10 seconds; ten minutes is two orders of magnitude above that. The count bound is what stops a burst from growing the map without limit — at the ~10 writes/s profile these bounds are written for, the time window holds roughly 6 000 entries, so the count ceiling is ~16× headroom.

The window is measured in leader-stamped time, not in any node’s local clock. That is a correctness requirement, not a detail: every node must reach the same expiry decision for the same log position, or two replicas could disagree about whether your retry is a duplicate — one applying it, one not.

A forward clock jump breaks this structurally, and the database cannot fix it for you. If a leader’s clock runs more than the retention window ahead of real time, one write it stamps is enough to move the retention watermark permanently into the future: every correctly-stamped token after it is born expired, so the very next retry duplicates — and the watermark is monotone, so no amount of correct clocks afterwards moves it back (issue #1137). The in-database signal is a WARN in the leader log, which nobody reads at 3am. Synchronised clocks with alerting are therefore a hard precondition for relying on tokens, not a recommendation — see Production Deployment. A cluster whose clocks are all equally wrong is unaffected; it is skew between leaders larger than the retention window that hurts.

Measured cost (2026-08-23, RFC-0024 / #1080, on a 62 GB / NVMe box, release build, allocated disk blocks): ~293 bytes per live token at 10 000 entries; a database sitting at the 100 000 ceiling holds ~25 MB of map (25 309 184 B measured at the real ceiling, #1151, 2026-09-01), and the 10-minute window at ~10 writes/s holds ~1.8 MB. A write that carries a token costs about 9% more on the apply path than the same write without one (1462 → 1342 single-entry applies/s in the same harness); a write that carries no token is unaffected.

When the map is full, writes are let through — not blocked

If a burst fills the map to its ceiling, patinaDB evicts the oldest tokens early and accepts the write. It does not refuse writes to protect the map.

That is a deliberate trade, and it has a real price on both sides:

  • Refusing writes would introduce a failure mode that does not exist today, and it would appear during a failover retry storm — precisely when the cluster is already degraded and every client is already retrying. It turns a duplicate into an outage.
  • Letting writes through means a retry whose token was just evicted is treated as new, and duplicates after all. That is the honest price: a gap in exactly the scenario the feature exists for.

Because that gap would otherwise be invisible, it is counted and logged: the metric patinadb_raft_idempotency_capacity_evictions_total and a WARN on the leader fire whenever a token is evicted early. If you see either, your guarantee is the capacity bound, not the time bound — size your write rate or your expectations accordingly.

This has been measured against the real ceiling (#1151, 2026-09-01, bench_idempotency_retry_storm): a burst of 150 000 token records — 1.5× the ceiling — accepts every record, evicts the oldest 50 000 tokens with every eviction counted and logged. The benchmark then looks up every token before recording any retry tokens: 50 000 lookups miss (33%). It subsequently records those missing tokens, which evicts another 50 000 still-valid entries. This measures the native token table; it does not execute the application mutations or an end-to-end concurrent client retry storm.

The measured 33% is not an upper bound on duplicated writes. In a live workload, an unrecognised retry can apply again and insert a fresh token before another retry arrives. That insertion can evict a token which was still protected at the start of the retry wave. Retry order and concurrent writes therefore affect which requests remain protected. Capacity-eviction counters and warnings indicate lost protection, not a safe percentage of retries. Once a token is absent, neither reuse of that token nor a fresh token establishes that the original mutation did not commit; reconcile through the application ledger/business identity or apply an explicit application retry policy.

Two fields, two different questions

The answer to a write carries two token-related fields. They are not two views of the same thing, and reading one as the other is the mistake to avoid:

fieldthe question it answers
dedup_activeWas the token mechanism active for this write?
duplicate_ofAnd did it fire — was this write a repeat?

dedup_active is not the duplicate signal. It is true for a perfectly ordinary, first-time, brand-new write — that is the normal case, and it means “you are protected”, not “this already happened”. The duplicate signal is duplicate_of alone. A client that treats dedup_active: true as “this was a duplicate” concludes that every protected write already happened, and may discard results that are real.

Read them in that order — first whether the protection was on, then whether it caught something:

  • dedup_active: false — the mechanism was not active for this write, which therefore has the ordinary at-least-once contract described earlier in this chapter. Two causes: you sent no token, or — the one to watch — a rolling upgrade is in progress. While any cluster member is still on an older protocol version, patinaDB falls back to the older wire format so writes keep flowing, and the token is dropped along the way. Writes never stop; the protection pauses.
  • dedup_active: true + no duplicate_of — protection was on, and this write is new. This is the normal, healthy answer, and it is what the large majority of your writes will return. The write applied.
  • dedup_active: true + duplicate_of: <index> — protection was on and it fired: this exact request had already committed. The write applied nothing, and <index> is where the original landed.

The same write, twice — side by side

The two answers below are the same request, sent twice. Reading them together is the fastest way to see which field carries which meaning:

// First delivery — an ordinary, successful, brand-new write.
{ "dedup_active": true,          // the protection was on ...
  // "duplicate_of" is ABSENT    // ... and had nothing to catch
  "log_index": 4711,
  "stats": { "nodes_created": 1 } }

// The retry, after the first response was lost.
{ "dedup_active": true,          // the protection was on ...
  "duplicate_of": 4711,          // ... and it fired: already committed, at 4711
  "log_index": 4711,             // the ORIGINAL's index — usable as a bookmark
  // no "stats" at all — same as any write that changed nothing
}

dedup_active is true in both. It is not what tells them apart — duplicate_of is.

A client that sends tokens and never reads dedup_active will believe it is protected throughout an upgrade window when it is not. That is the one way to be worse off with tokens than without them, and reading one field prevents it.

What a recognised duplicate looks like

  • It is a success, not an error. It is never a 409, never a conflict, and never something to retry.
  • It reports no write statistics — the stats map is omitted entirely, exactly as it is for any write that changed nothing. They describe this call, and this call changed nothing. They are deliberately not the original’s numbers: those cannot be reconstructed (a MERGE that created on the first attempt merely matches on the retry), and reporting a re-derived figure as history would be a fabrication.
  • Read-your-writes still works. The position it reports is the original’s, so a bookmark or min_index taken from a deduplicated retry waits for the entry that actually carries your effect.

So a duplicate reads as: this call did nothing, because it already happened — here is where.

Not covered

Bulk and chunked loads (CALL {} IN TRANSACTIONS, bulk upsert) are many log entries, not one request; a single token cannot describe them, and they carry none. Schema and administrative commands (CREATE DATABASE, CREATE INDEX, …) are already idempotent by name and need no token.

How to retry

Re-read, don’t replay. The important part of retrying a Neo.TransientError.General.TransientError write-write conflict is not resending the same bytes — it is recomputing the write against current state. A read-modify-write must re-read:

for attempt in range(5):
    current = read_counter(key)                      # re-read every attempt
    resp = write_counter(key, current + 1)           # recompute from it
    if resp.ok:
        break
    if not resp.json().get("retryable"):
        raise ApplicationError(...)
    sleep(backoff(attempt))
else:
    raise RetriesExhausted(...)

Resending a value computed from a stale read will simply be rejected again — or, worse, silently overwrite the concurrent write you were told about.

Bound your retries. Use exponential backoff with jitter (e.g. 50 ms doubling to ~2 s, ±25 % random) and a hard attempt cap. Unbounded retry loops turn a brief contention spike into a self-sustaining stampede.

Make writes idempotent where you can. MERGE on a business key, rather than CREATE, means a retry after an ambiguous failure (a timeout where you never learned the outcome) cannot create a duplicate.

Size the retry budget for a cluster restart, not just for contention. The longest window your client has to ride out is not a contended key — it is the cluster coming back after a total outage (a datacentre power event, a node-pool restart). While the cluster re-forms, a node can already report a current_leader read from persisted Raft state, naming a node that is not yet serving; writes in that window are refused as retryable.

Measured (#1150, 2026-08-30): median 4.13 s, worst observed 4.32 s. Three independent runs of 20 simultaneous full-cluster restarts each (n = 60), on a 16-core Linux box at loadavg 13–22, --release, 3 voters on loopback, polled at 50 ms, timed from “all three processes restarted” to “a write returns 200”. The median reproduced to within 0.7 % across the three runs; a minority of restarts complete in ~0.3 s, which is the lucky case and not what a budget is sized for.

Retry writes for at least ~10 seconds before declaring the database down. That leaves roughly 2.3× headroom over the worst window observed. It is short, but far longer than a single-shot write or a two-attempt budget will survive — a client that gives up after one try will report an outage the cluster does not have.

This number is from a small database, and the ceiling is not. These nodes held a handful of vertices. A cold start also has to open the storage file, load the registry and replay the Raft log, all of which grow with the data — so at customer scale the window is longer by an unmeasured amount. The 10-second advice is calibrated against the small-database window with the headroom above; if your database is large, measure your own restart (scripts/cold-start-window.sh) rather than inheriting this figure.

Keep transactions short

An open explicit transaction (BEGIN … COMMIT) pins a read version for its whole lifetime. Long-lived transactions:

  • hold back storage reclamation, so the data directory grows;
  • widen the window for a write–write conflict at COMMIT, so they retry more.

Read the data you need, commit, and do your application-side thinking outside the transaction. Do not hold a transaction open across a user interaction or a network call to another service.

Reading your own writes

Following a write, a read routed to a different replica may not have applied it yet. Two options:

  • Bolt drivers: use the same session. Bookmarks are handled for you, and a read waits for the replica to catch up (or reports BookmarkTimeout — retryable).
  • REST: pass the write response’s log_index back as ?min_index=<log_index> on the read — any node (followers included) waits until it has applied the write, then serves (a timeout is a retryable BookmarkTimeout). See REST API → Read-your-writes over REST. Alternatively send the read to the leader, or pass "consistency": "linearizable" (leader-only; it waits for a read barrier).

See High Availability for the routing details.

Checklist

Before going to production, verify your client:

  • keys retry decisions on retryable, not on the HTTP status or message
  • treats NotALeader as re-route, using leader_addr
  • re-reads before recomputing a retried read-modify-write
  • bounds retries (backoff + jitter + attempt cap)
  • uses MERGE-on-business-key so an ambiguous retry cannot duplicate — the measured evidence says a write that timed out has most likely already committed
  • keeps explicit transactions short and never open across external calls
  • surfaces a non-retryable error to the caller instead of looping
  • sends an idempotency token on retried writes (Idempotency-Key header / idempotency_key metadata), derives each token from its own request identity, never from a per-attempt value; reads dedup_active to know whether the protection was active at all; and keys “this was a duplicate” on duplicate_of, never on dedup_active — noting that over an explicit Bolt transaction these fields are not readable from application code (they ride the COMMIT success, which drivers do not surface)

The deployment rehearsal exercises this contract against a real cluster under a forced leader failover — run it before you launch.

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) so a load balancer or orchestrator can reach them without credentials, and neither is ever shed by the concurrency limiter. /metrics is not in that set: it needs a credential and the global Admin role unless --insecure-open-metrics is passed (see the manual’s Security chapter).

EndpointSemanticsPoint it at
GET /healthLiveness — 200 whenever the process is up, always.Kubernetes liveness probe (restart the container if it stops answering).
GET /readyReadiness — 200 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).
no_quorumThis node is the leader but has not been acknowledged by a quorum for over 5s, so it cannot commit anything. The ordinary cause is that a majority of voters is down or unreachable — e.g. two of three nodes gone. Note the node still reports itself as leader (Raft does not step a quorum-less leader down) and can still answer reads, but every write fails; it is taken out of rotation deliberately, because a leader in this state may already have been superseded and cannot know it, so its reads are a stale-read risk rather than a useful degraded mode. Fix by bringing voters back, not by restarting the survivor.
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 }

Readiness is not a guarantee that the next write finishes within a particular deadline. After a whole-cluster restart, a leader can have its local state fully applied and fresh quorum contact while rebuilding volatile follower replication positions. Retained large log entries can make that recovery significant (#1261, repository docs/customer-blackbox-million-1261-2026-09-16.md). For a planned restart/admission check, also inspect the leader’s patinadb_raft_follower_lag for the replicas that your deployment expects to have caught up; absent series do not prove zero lag. A zero-valued self entry may also be present. This diagnostic is not a replacement for quorum rules or causal bookmarks, and waiting for an intentionally unavailable member would unnecessarily block a healthy surviving quorum.

Because /ready fails when a node is leaderless, lagging, mid-install, or degraded, pointing a load balancer at it removes known-unready nodes from the rotation. It does not replace bookmark waits for causal reads. 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

Startup snapshot recovery

When Raft must reconstruct a local checkpoint after log purging, it preserves an existing causal domain before peer-version discovery has completed (#1494). That local recovery does not confirm peer capabilities: serving the cached checkpoint remains gated on compatible, confirmed member versions. Recovery can therefore finish locally while replication is still waiting for discovery.

During local Raft initialization, a recovered current-format checkpoint in the same causal domain can be retained even if its frontier is older than the already-applied state. This avoids rebuilding it solely because peer discovery has not started. The permission is limited to the initializing task and store; replication workers still check peer compatibility. Missing or invalid local checkpoints can still require reconstruction; large-file recovery latency and the current four-GiB pressure profile remain under qualification.

After initialization, a completed peer-version poll can still leave unreachable members unconfirmed. In that case local causal checkpoints use logical format7 and preserve pending history restores and replay receipts (#1498). They remain unavailable for replication until compatible versions are confirmed. Known pre21 peers retain the existing refusal. This avoids treating discovery alone as a fatal storage error; it does not make incompatible members compatible or remove the disk work needed to create a checkpoint. State-only snapshots still require the separate history backfill for older timeline data.

Persistent-store durable commits retain redb’s quick-repair metadata, including the terminal durability barrier after a deferred multi-tree write (#1307). This avoids the full allocator/checksum reconstruction normally needed after an unclean shutdown. Deferred writes still roll back unless their durability barrier completed. This adds allocator metadata and two-phase commit work to durable writes; it is not free and does not make all node startup instantaneous. Older files without this metadata may still need a full repair on their first unclean reopen. Never skip integrity checks or repeatedly kill a repairing node to meet a readiness timeout. Snapshot reconstruction, log replay, index recovery and cluster catch-up are separate startup costs.

Before starting Raft or pinning boot snapshot views, the server performs an empty durable preparation commit on each opened graph, after recovery of a pending snapshot install. redb can then reclaim pages whose old read versions became unpinned by the restart before the first replicated write arrives. Startup logs report startup storage preparation started and startup storage preparation complete per database. A storage error prevents startup instead of admitting a node with failed preparation. Budget this work in startup probes: it may involve disk I/O and has no fixed latency bound. This does not change graph contents, history HEAD, replication positions or normal write durability, and does not replace cluster catch-up or compaction. Embedded callers can request the same boundary with Dataset::prepare_for_serving() before admitting work; calls inside an active deferred transaction are rejected.

If a Raft log prefix has already been purged and its cached snapshot is missing or incompatible with the current causal domain, startup may rebuild a snapshot before the listener becomes available. Budget this separately from ordinary process restart and inspect the snapshot-build spans; it can involve substantial disk I/O. Startup recovery does not wait for the peer-version publisher, which starts after Raft construction. Once the application is initialized, normal snapshot builds still use the bounded first-version-publication gate.

Correlation and export

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.
  • A valid inbound W3C traceparent supplies both correlation and remote span parenting through the same validator. Invalid or duplicate parent headers are ignored. Vendor tracestate and baggage are not imported in this increment.

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). Only explicitly selected diagnostic spans are eligible for export, not ordinary log events. Outbound /raft/* peer RPCs propagate the current OTel context when present; this alone does not guarantee propagation through every internal Raft task or batch. 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. Exporter initialization or sampling configuration failure logs a warning at startup and the node runs normally with export disabled (initialization failure is never fatal; once initialized, network failures during actual export are async/best-effort and never block a request). With no endpoint set, no OTLP provider or exporter is installed; existing logs/spans and header validation still have overhead.

RFC0033’s initial implementation samples 1% of trace IDs by default, with a node-wide ceiling of 128 admitted spans per one-second window. Configure these using PATINADB_TRACE_SAMPLE_PERCENT (0–100) and PATINADB_TRACE_SPANS_PER_SECOND (1–1024). These are configuration limits, not measured performance figures. Remote sampled flags do not bypass either limit. Invalid sampling configuration disables export with a startup warning, not the database. Saturation may produce incomplete traces; policy rejection is counted in patinadb_trace_dropped_total by bounded reason (rate_limit, policy_busy).

The SDK exporter queue holds at most 512 spans, with batches of at most 64 and a two-second HTTP export timeout. Exported spans contain at most 16 allowlisted scalar attributes, with strings capped at 128 bytes; events and links are not exported yet. Unknown/raw paths are folded to <unmatched>. Request IDs unsafe for diagnostic attributes are redacted in the span while response echo behavior remains compatible. Existing ordinary application logs are not covered by this export allowlist. Export goes only to the operator’s endpoint, never to licensing heartbeat telemetry.

Current coverage is deliberately narrow: HTTP handler spans, Bolt RUN dispatch, bookmark admission, snapshot build and its blocking write phase, and blocking snapshot restore, plus local fulltext/vector search phases. RUN dispatch is not the full query/result lifecycle, and HTTP handler completion is not streamed-body completion. Snapshot workers retain their parent span across the blocking-pool handoff and cancellation.

Search diagnostics expose two logical levels below the search operation:

Streaming Bolt graph queries also expose db.query.stream with preparation, planning, eligible result-cache lookup, fast-path selection, and execution phases. Execution measures iterator construction and eager operators; lazy row consumption and client backpressure remain inside the enclosing stream. Aggregation separates input consumption/folding from finalization. Parent and child durations overlap; do not add them. These spans contain no query text or parameters and are subject to the same sampling/export limits below.

db.search.fulltext
  .lookup / .parse
  .index
    .statistics / .evaluate / .rank
db.search.vector
  .lookup
  .index
    .centroids / .candidates / .rank

Fulltext evaluation includes posting reads/decoding and recursive query scoring; vector candidates include posting reads/decoding, scoring and top-k maintenance. These are fused phases, not independent I/O and CPU timers. Rank includes result collection and ordering, not entity hydration or Bolt delivery. Missing indexes, invalid dimensions and empty vector requests terminate before later phases. There are no per-document, per-term or per-centroid spans. Index names, query text, vectors and entity IDs are not recorded. These spans do not change search or bookmark semantics; disabled-export overhead is not claimed to be zero.

The OTLP HTTP client runs in blocking mode on the SDK’s dedicated batch worker, not on request executor threads. A local 1M diagnostic run captured nested spans (#1261, docs/latest-onboarding-traced-1261-2026-09-15.md); this is not a paired overhead qualification or full RFC0033 acceptance.

Snapshot diagnostics now include logical subphases: checkpoint table copy, durable commit, close and sync; packaging copy/hash; installation copy/hash, sync, open-probe and swap. Verification separates blocking-pool admission from read/hash work. Detached receive finalization retains its parent context. These support operation → phase → subphase drill-down, not per-record tracing. Durations are wall time, including waits; nested or overlapping spans must not be summed. Copy/hash and receive/write remain fused phases. Missing children can also mean sampling/rate/queue drops, not absent work. Cross-Raft parentage and background history coverage are not complete; no overhead qualification yet.

Still pending from RFC0033: complete transaction/PULL/body lifecycles, targeted capture, Bolt transaction-metadata propagation, queue byte/drop accounting, global live-span/per-operation budgets, bounded provider shutdown, richer phase coverage and overhead qualification. The initial queue/attribute bounds are not a demonstrated whole-process memory ceiling. No public trace endpoint or automatic slow-trace retention is provided.

Diagnosing causal waits and Raft apply

For a local incident capture, enable RUST_LOG=info,patinadb_raft::progress=debug,patinadb::apply_progress=debug,patinadb::squash_progress=debug.

Legacy squash emits legacy_squash_attempt events before each phase. Differences between cumulative elapsed_us values belong to the preceding phase. Correlate progress_id and rust_thread with the enclosing Raft apply; IDs are local to each event family. The phases distinguish reconstruction-slot admission, chain selection, reconstruction, operation materialization/hash, genesis writes, write and retention locks, revalidation, reparenting, prefix deletion and terminal sync. An end marked unfinished is not success; ok, noop and retry are explicit. Floor refusal precedes the attempt and emits no squash event. These events do not alter durability or prove that a sync request incurred physical I/O. PATINADB_LOG=json makes these events JSON lines. The target is disabled at ordinary info level; it uses the existing logging filter and does not change query budgets, replication, durability or retry policy.

Progress scopes have a process-local progress_id, optional first/last Raft indexes, elapsed microseconds, a phase and an outcome. Raft events on Linux include the native thread ID (native_tid); other platforms report zero. Core apply events include rust_thread; match it to the enclosing synchronous Raft write event to associate the native thread. Core and Raft scope IDs use separate counters; keep the event target as well as process generation in the correlation key. A dispatch scope can move from an async worker into a blocking worker, so correlate by scope ID and index as well as thread. IDs restart with the process; keep node and process generation when correlating logs across a restart. Nested graph-barrier and metadata events run on the apply worker. Durations overlap and include waits; do not add nested durations.

The events distinguish receiving an AppendEntries range and advertised commit index, entering the state-machine apply, acquiring its cut lock, queueing and starting blocking work, individual entry handling, graph buffer flush/sync, background-snapshot admission and state-machine metadata persistence. A causal wait records its required index and whether it completed, timed out or shut down. Conditional writes also distinguish replay detection, constraint fingerprint, preimage checks and the main write. Core apply phases separate prevalidation, graph/index updates, fulltext, vectors, history, deferred scope exit, the terminal sync request and snapshot admission. Phase events precede the named operation; the next event bounds the interval. An outer group can defer the core sync request: only its later group barrier provides the durable batch boundary. Receiving or acknowledging an append does not prove graph application. A scope ending with unfinished means the caller did not reach its explicit completion marker; it can be an error, cancellation or unwinding, not a diagnosis of the underlying failure. An entry’s processing result is separate from the final durable batch completion and OpenRaft’s published applied position.

These events record protocol positions and fixed phase names, not query text, properties, credentials or error bodies. They are ordinary local log events, not additional OTLP spans. Log volume and synchronous log writes can affect the incident being measured; enable this target for a bounded capture and retain the matching source/binary identity. No performance or latency guarantee follows from enabling diagnostics. The configured three-hour memory-pressure soak passed; broader latency and capacity bounds remain workload-dependent (#1409). That run qualifies the stabilization fixes, not the RFC0036 admission governor. The memory budget defaults are active on the server; their performance acceptance remains open.

Slow maintenance warnings

Four WARN events are logged at the default level when maintenance work is slow. They name a database and fixed phase names, never query text or values.

MessageThresholdPhases reported
slow tag snapshot job held the artifact lane5 sprepare_cut_wait, prepare, build, publish_cut_wait, publish
slow squash preparation5 slane (artifact or small), lane_wait_ms, plan_ms
slow squash plan5 sfloor_head, read_lease, chain, cutoff, reconstruct, identity
slow fork1 sresolve_ms, reconstruct_ms, seed_ms, publish_ms (plus operations, ok)
history read lease waited behind a retention writer1 swriter active/waiting and reader counts when the wait began

Phase durations are in milliseconds and are sequential within one event. A tag job and a squash preparation of a large database share one artifact lane per node, so a long tag build explains a long squash lane_wait_ms on the same node. Squashes of databases with at most 4096 live vertices and edges use the separate small lane. These events identify where time went, not why; they do not change any timeout, admission or retry policy.

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.

Capacity assumption: bounded label / property-name cardinality

patinaDB interns every label, relationship type, and property name into a process-global arena (Label = internment::Intern<String>, patinadb::models::Label) that never frees for the lifetime of the process. This is a deliberate design choice: identifiers are expected to be bounded-cardinality (a schema’s worth of distinct names), so a small, ever-growing table is fine.

The important consequence: distinct names come from query text, not just from DDL. Every parsed query interns the identifiers it mentions, so a workload that generates fresh property names or labels per request (e.g. SET n.{timestamp} = … with a varying name, or generated Cypher with embedded ids in identifier position) grows the arena — and with it resident memory — for as long as the process runs. A restart resets it.

Operating guidance:

  • Treat identifier cardinality as a schema property. If your application never mints new labels/property names at runtime, no action is needed — this is the configuration the design assumes.
  • Observe it. label_intern_count() (patinadb::models) reports the number of distinct strings interned so far in the process. It is currently a library-level observable (used by the test-suite), not yet exported on /metrics or a /mgmt endpoint — wiring it into metrics and alerting on sustained growth (a monotonic climb after schema-stable warm-up means somebody is minting identifiers at runtime) is the recommended follow-up (unverified until wired — today, watch process RSS as the proxy).
  • Multi-tenant caveat. Do not expose raw query text to untrusted tenants: a tenant who varies identifier strings per request can grow the arena without bound (a slow memory-exhaustion vector). Map/parameter keys and string values are safe — they are EcoString, not interned — it is only identifier positions (labels, relationship types, property names, variables) that intern.

Crash-loop quarantine (deterministic fail-stop faults)

patinaDB’s write path fail-stops (aborts the process) when a partially landed write cannot be rolled back in-process — by design: a loud crash with clean recovery beats silently fossilizing torn state. When the underlying fault is transient (a real I/O hiccup), the restart simply works. When it is deterministic — durable on-disk corruption that the Raft replay re-reads on every boot — the node would crash-loop forever.

The server detects that loop empirically (never by guessing from the error’s type — an error type cannot prove determinism): each fail-stop abort appends one line to <data-dir>/torn_aborts.tsv, and at boot the node checks it. Three aborts with the identical signature within 15 minutes put the node into quarantine instead of booting:

  • the process stays up and stops looping; Raft and Bolt are not started, the database directory is not opened;
  • every HTTP route answers 503 with {"quarantined": true, "reason": …} (plaintext, health-probe friendly);
  • the metric patinadb_quarantined is 1, and the boot log states the reason and this runbook;
  • nothing is deleted and nothing is auto-repaired.

That last point is deliberate. The cluster usually holds the state redundantly, so the obvious “fix” — wipe this node and re-fetch a snapshot — is usually right. But if the corruption travelled through the replicated log itself, every replica has it, and auto-wiping would destroy the last locally inspectable artifact while re-importing the damage. A human decides:

  1. Inspect: keep a copy of the data directory (and torn_aborts.tsv — it names the failing operation and error) if you want a post-mortem.
  2. Check the peers: if the other members are healthy and serving, this is single-node damage. Remove the node’s data directory, delete torn_aborts.tsv, and start it again — it re-joins and receives a fresh snapshot from the leader.
  3. If peers show the same aborts (replicated damage): restore the cluster from a backup (restore), accepting the documented history consequences; engram-history loss vs. full rebuild is an explicit operator trade-off, not something the node may decide alone.
  4. Supervised override: PATINADB_QUARANTINE_OVERRIDE=1 boots normally despite the verdict (e.g. after fixing the disk underneath) — the override is logged loudly.

Stale markers are harmless: only aborts younger than 15 minutes count, so a node that aborted long ago (and has run fine since) boots normally even if the file was never cleaned up.

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

What each file and table holds is described in How Storage Works.

patinaDB’s B-tree storage engine reuses freed pages internally. Native commits or close may shrink unused tail space, but the file need not return to the size of its live graph. After bulk loading, deletes, history maintenance or a period of long-lived readers, the per-db .redb file can retain a large high-water allocation.

  • Snapshot-count retention manages separate _engram_snapshots/*.snap files; it is not compaction of the main graph file.
  • Deleting graph/history records can free internal pages for later writes without returning all of that space to the filesystem.
  • A pinned read version preserves older pages while writes continue. Slower paced snapshots can extend this retention window. Track patinadb_oldest_pinned_read_seconds and patinadb_pinned_reads alongside allocated file bytes and free disk; those gauges cover owned read-snapshot handles, not every internal redb reader.

The #1409 owned-copy checks demonstrated native reuse and partial offline reclamation with unchanged logical content. They did not establish bounded long-term disk growth. Measure allocated blocks, not just apparent file length, and distinguish main files from snapshot sidecars and temporary build files.

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 (Disaster Recovery 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.

Development-only transaction memory refusal

Explicit admitted Bolt COMMIT also reserves its new CAS token containers, fresh value copies, label arrays and sorting scratch; local request/entry clones share those data owners. Refusal occurs before proposal and reports no effect. Constraint catalog source buffers, native pages and history decoding remain outside that accounting, as do fallback preimages and other write paths. A large old property can therefore make COMMIT refuse even when the small update itself fitted RUN. The structural OCC tables and retained constraint values now share the same allowance, including intervening Engrams checked since BEGIN. The existing accumulator keeps charged maps and additive UNIQUE tuples across history pages. Final constraint keys and their temporary construction storage also share this allowance. A small input can still need many numeric composite-key variants; refusal during that expansion occurs before proposal with no effect. Existing vertex/edge properties needed to complete a key now decode under the same allowance. A small update can therefore be refused while reading an untouched, large key column. The source value stays charged until the key construction using it ends; errors are not converted into an absent property. This also applies to intervening history checks and the pinned CAS view. Label source arrays and temporary registry IDs now reserve before allocation as well; a vertex with many secondary labels can therefore exhaust the allowance even when the changed property is small. Read/corruption errors cannot become an empty successful CAS label preimage. A small write may therefore refuse because its conflict check meets a large foreign commit or retains the IDs of many small intervening commits. The ID list now reserves capacity before growth; metadata strings are borrowed during the walk. This does not yet cover decoded Engram bodies. A new transaction after RESET starts from a fresh base; it does not replay an unknown write. The legacy wire schema and server defaults are unchanged; full qualification is still pending. A refused unproposed COMMIT leaves the graph unchanged and moves the Bolt connection into its ordinary failed state; RESET permits new work. Reducing only the new value’s size may not help if the captured old value is what exceeds the allowance. The proposed RFC 0036 ENV settings remain unavailable in the server; the current coverage is exercised through the explicit Rust governor API.

In that development profile, projection DISTINCT accounts its retained key set. Explicit-column DISTINCT also accounts buffered output slots, consumes all input before output and preserves the existing cap on total input rows, including duplicates. A covered allocation refusal during private resolve returns a typed resource error without publishing a partial transaction or Engram. Original row payloads and other operator allocations still need coverage; see the current limitations.

When an embedded host supplies the development governor, outbound AppendEntries and Vote bodies also use its protected Apply allowance. A body reservation failure is a peer transport failure and uses ordinary Raft recovery/retry; it must not be interpreted as patinadb_effect=none for a proposed write. Existing RPC deadlines and receiver limits remain unchanged. The default CLI still leaves the development governor disabled; this adapter alone does not qualify automatic sizing, complete apply capacity or a node-wide memory bound.

Parallel query tuning

PATINADB_QUERY_THREADS=1 remains the default. For large eligible read queries, raise it gradually and measure latency with identical query text, data, cache settings and result checks. It caps the caller plus helpers for each query. PATINADB_QUERY_POOL_THREADS caps persistent background query workers across concurrent queries. Callers always work on their own query, even when the pool is occupied. Eligible dispatches now decline to serial before parallel preparation when all background workers are busy. With partial availability, the cap becomes min(query cap, idle workers + 1). This sampled capacity is not reserved; later queries can compete for it. Raising the cap is reasonable to evaluate for large eligible reads when the host has spare CPU capacity and measured latency improves. The load check does not establish saturated-workload throughput or protect Raft latency by itself. Leave CPU capacity for Raft and I/O; the pool does not reserve it automatically. These settings do not parallelize every query shape. Raising the cap keeps factorized path counts, grouped path counts, covering aggregation and deferred-prefix filters available. An applicable serial specialization takes priority over parallel work. EXPLAIN shows aggregate access choices and deferred-prefix candidates; PROFILE records the specialization actually used (and the runtime prefix cut). This priority rule does not guarantee that every other admitted parallel query is faster: compare latency and throughput under your workload before raising the cap.

PATINADB_QUERY_MORSELS_PER_WORKER controls the target scan partition count (default 1 times the per-query worker cap, clamped to 1–64). Plain scans split the UUID key space without a preliminary label scan; traversal drivers retain key-count boundaries. More partitions may improve work distribution but also increase partial-result and merge overhead. Measure both single-query latency and concurrent throughput before changing it. At cap 4, up to three helpers and the caller execute work: the same four executing threads as the former four scoped workers with a waiting caller. Cap 1 uses the serial executor without constructing a pool job. Use --check-config to validate the integer settings before restarting. Restart the process when changing pool size or the per-query cap. Worker memory is charged to the same query budget, and memory limits remain in force.

PROFILE reports parallel.load with idle capacity and the admitted cap or decline. EXPLAIN does not sample runtime load. The existing /metrics endpoint exposes patinadb_query_pool_busy_workers and patinadb_query_pool_size (background workers only), plus patinadb_query_pool_jobs_total, patinadb_query_pool_morsels_total, and patinadb_query_pool_load_declined_total. Jobs and morsels include merge work and errors; load declines count operator attempts rather than distinct queries. An unused pool reports size zero.

Parallel ordered pagination

PATINADB_QUERY_THREADS remains 1 by default. With a larger cap, eligible read-only fixed-length traversals can use parallel top-K for ORDER BY with LIMIT, including SKIP. Each partial retains up to SKIP + LIMIT rows, so deep pagination increases the working set. Seeded MATCH pipelines can partition their materialized outer rows. Results retain serial tie order; projections and workers read one pinned snapshot. Outer preparation still runs serially. A single selected start can instead expand a fixed-length prefix serially and partition its frontier when sampled work and actual frontier size justify workers. This currently covers one-direction paths from the first endpoint, with distinct binding names and one label per node. Ordered drivers and unsupported selected paths retain serial execution, as do Apply outers with unestimated cardinality (including DISTINCT, aggregation and UNWIND). Admission uses estimated outer rows and bounded fan-out samples before materializing the outer; unavailable estimates retain serial execution. Frontier and per-morsel continuation buffers also consume query memory and may cause serial fallback.

Grouped consumers may run serially over ordered parallel Apply output, retaining serial aggregation and collection order.

Use EXPLAIN’s parallel.topk / parallel.apply lines to inspect structural eligibility and cost declines, and PROFILE to check actual engagement or fallback. A candidate may remain serial because of the row threshold, unsupported inner shape, memory admission or snapshot preparation. Existing specialized index paths can take precedence. Keys outside the proven scalar ordering domains (including large integers and temporal/compound keys) also use serial replay. Worker failure or refusal replays through the serial executor before publishing any rows. Variable-length inner traversals, CALL-import Apply and parallel unbounded sorting are outside this extension. More threads are not a demonstrated throughput improvement; test representative queries and memory budgets before changing the cap. See RFC 0041 for the generated-graph measurement limits.

Inline secondary labels

Physical snapshots carrying schema-8 inline labels require protocol 22 (snapshot format 8). During rolling upgrades, protocol-21 clusters use logical format 7, including history-restore state and causal identity. Older domainless clusters use their existing logical formats; scoped snapshots require at least protocol 21. History-restore stages or receipts also require protocol 21 for transfer.

RFC 0040 inline secondary-label readiness runs by default at database open, including fresh empty stores. PATINADB_INLINE_LABELS defaults on and is read once per process; restart to change it. Boolean values follow the normal runtime-knob grammar. Opening a store without READY rebuilds its derived adjacency values before publishing READY. Interrupted rebuilds leave READY absent and can be retried. Rebuild stages each adjacency tree separately and atomically swaps it into place; the original edge keys survive an interrupted staging pass. Temporary disk space is required until the old pages can be reclaimed. Offline compact can reclaim those pages after the database closes. One private SF1-copy measurement is recorded in the RFC 0040 storage evidence (#1442): first open plus packed rebuild took about 95 s; allocated space after offline compaction grew by 0.68%. These SF1 figures are not a general capacity or migration-time guarantee.

This switch controls automatic open-time ensure, not live writer maintenance or whether readers honor an already-present READY marker. Setting it back to 0 therefore does not undo a rebuild or disable trusted reads in an existing READY store. Use separate original store copies for before/after experiments. Bulk imports and snapshot installs write final inline values. Successful bulk updates preserve readiness with a fresh cache token when the prior graph was ready or empty. Updating an already stale nonempty graph does not certify its untouched entries; reads continue to use secondary metadata until rebuild. Live label changes maintain inline values and revoke older cache trust. The deferred insert and bounded label-change costs are accepted in ADR 0018; see RFC 0040 “Increment 1 measurements” (#1442).

Independent backend: transaction admission

For the independent Rust search backend, SEARCH_DB_TIMEOUT_SECONDS retains its 8-second default and bounds transaction admission as well as the existing response wait. A server Neo.TransientError.General.DatabaseUnavailable refusal of explicit BEGIN is retried after a 25 ms policy delay within that original budget. Every BEGIN retains the configured database and causal bookmarks. There is no new environment toggle. This handling never retries RUN or COMMIT, never retries a transport failure, and never starts a write from a BEGIN response that arrives after the admission deadline; it explicitly rolls that late transaction back. Subsequent HTTP503/timeout write outcomes remain ambiguous and require exact readback, not blind replay (#1483).

Diagnosing a write refused before proposal

For non-chunked HTTP upserts and REST autocommit data writes, READ_PUBLICATION_IN_PROGRESS together with write_outcome: "not_submitted" certifies refusal before this request entered Raft. Count it as a rejected operation, not a committed write. Other 503 responses, transport failures and timeouts remain governed by the existing ambiguous-write policy. See the client contract. The receipt adds no automatic retry or timeout change (#1483).

Authentication & TLS

Authentication

The legacy shared --auth-user / --auth-password credential remains available for local root/break-glass administration and existing deployments. New IAM adds separate local principals, service API keys, and (Enterprise) federated OIDC/OAuth identities; it does not make provider deletion an authentication bypass. See Identity and access management for edition boundaries, provider setup, session behavior, and restore semantics.

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). After identity-provider configuration has been used, the replicated IAM enforcement marker prevents anonymous open-admin; deleting the last provider does not clear it. See IAM.

    # 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
    
  • Set a long, random --auth-password — this now matters more than it used to (issue #1065). The root-admin username is EXEMPT from the brute-force throttle described below, by design: a single-service-principal deployment must never be able to lock itself out of its own account (see the rotation runbook), and the credential is a constant-time compare with no argon2 cost, so throttling it bought no DoS protection anyway. That trade only holds if the password itself carries real entropy — a short or guessable one now has NO per-minute guess cap standing between it and an attacker, where it used to have one. The server warns at startup (and again on POST /mgmt/rotate-admin-password) if the password is under 16 characters, but does not refuse to start on a short one (existing deployments shouldn’t get bricked by a new check) — treat the warning as a “fix this now”, not a “fix this eventually”. Generate one with e.g. openssl rand -base64 32.

  • 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). With authentication enabled, /version exposes only the database-agnostic history-completeness boolean publicly; its database-scoped floor map is returned solely to peers presenting the cluster secret. An explicitly open node retains the legacy map because every database is already public there.

  • /metrics: authenticated by default AND global-Admin-only — 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. A credential alone was not enough: any authenticated per-db Reader could read the same labels, which under closed-mode RBAC is exactly the cross-tenant enumeration SHOW DATABASES, the Bolt system shim and the /cypher db-lookup path all deliberately prevent. /metrics therefore carries the same global Admin floor as /mgmt/* (issue #693). Operator action: your Prometheus scrape must authenticate as a global Admin — the root --auth-user/--auth-password credential, or a directory user created with SET ROLE admin. A per-db Reader scrape now gets 403. 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) — that opt-out bypasses both the credential and the role floor. /health and /version stay open regardless.

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

  • Brute-force throttle (on by default), except for the root username (issue #1065). Failed logins from a directory (CREATE USER) account are rate-limited before the argon2 verify (argon2 is deliberately slow, so an unthrottled flood would be an amplification DoS): default 20 failed attempts per minute, budgeted per source IP and per username — a distributed attack spread across IPs still trips the username budget. Over budget → 429 on REST, an AuthenticationRateLimit failure on Bolt; a successful login clears the counters. Tune with --auth-max-attempts-per-min / PATINADB_AUTH_MAX_ATTEMPTS_PER_MIN; 0 disables it (a loud startup warning is logged). The root-admin username is never subject to this limiter at all — see the callout above; this is a deliberate carve-out, not a gap. Tests: tests/authlimit.rs, tests/secret_rotation.rs.

  • Recovering a locked-out directory user (issue #1071 review follow-up). A directory account IS subject to the throttle above (correctly — see the callout: it goes through argon2 and carries real guessable entropy, unlike root), so it CAN get locked out — idle-timeout churn, a stale cached credential during your own rotation, the same shape #1065 fixed for root. Recovery is admin-mediated, since a locked-out account cannot free itself and root is exempt from the very limiter it needs to clear: either ALTER USER <name> SET PASSWORD '...' (rotates the credential AND clears the lock, same as rotate-admin-password already does for root) or, when the password is still good and only the lock needs to go, POST /mgmt/unlock-user {"username": "<name>"}. Both are logged to the protected audit class (never subject to the count-based retention cap — see Security audit log). Scope, honestly stated: this clears the account’s own failed-attempt window; a source IP that independently exhausted its OWN per-IP budget (the same one that locked the account, if it was a single-source lockout) stays throttled regardless — deliberate, not a gap (see mgmt_unlock_user’s doc comment for why), and it self-heals once that IP’s window elapses. Tests: tests/unlock_user_1071.rs.

  • Argon2-verify cache for directory users (issue #1067). REST re-authenticates on every request (auth_layer runs on every route), so without a cache a directory (CREATE USER) account paid a full ~19 MiB argon2id verify PER REQUEST — for a 99%-read single-backend deployment (this profile) that makes the auth layer a hard throughput ceiling and creates a perverse incentive to use the (unthrottled) root credential for routine traffic just to dodge the cost. A successful verify is now cached for 60 seconds by default (--auth-verify-cache-ttl-secs / PATINADB_AUTH_VERIFY_CACHE_TTL_SECS, 0 disables). This TTL is a security trade-off, not a tuning knob: it bounds how long a REVOKED credential could still authenticate if the change reached the node through anything OTHER than UserStore’s own methods — in this codebase nothing does (every ALTER USER … SET PASSWORD / DROP USER / grant change goes through UserStore). Mutations advance a per-user generation under the directory lock and invalidate the cache. Cache hits and completed Argon2 verifications revalidate that generation; an older verification cannot repopulate a valid cache entry after revocation (#1265). This is an admission check against locally applied state, not cancellation of already-admitted work or instantaneous propagation to followers. Direct-DB writes bypassing UserStore are unsupported. A wrong password is NEVER answered from the cache — it always pays the full argon2 cost, so the failed-attempt rate limiter above is completely unaffected (only a repeated CORRECT password gets cheaper). The cache key is an HMAC keyed by a process-local, never-persisted random value (not a bare SHA-256(password)) — a fast keyless hash would itself be an offline-crackable artifact for the cache’s TTL; see cache_key_mac_key’s doc comment in rbac.rs for the full argument and its honest limits. Historical measurement before the generation checks, not current-cache throughput (this box, single- threaded, RAM-only, N=2000 sequential verifies — see rbac::tests::bench_verify_cache_throughput_1067, #[ignore], re-run with --ignored --nocapture): first run under heavy sibling-agent load (loadavg ~46–74) measured 50→101,282 verifies/s (~2020×); a second run on a quieter box (loadavg ~10–51) measured 68→98,376 verifies/s, a ~1443× (#1067) speedup. These absolute verifies/s numbers are load-sensitive and do not qualify the subsequently hardened cache implementation. Tests: rbac::tests (unit-level cache + invalidation proofs) and tests/rbac.rs’s alter_user_password_invalidates_the_verify_cache_over_rest_1067 / drop_user_invalidates_the_verify_cache_over_rest_1067 (the same invalidation proved over the real REST path).

  • 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"

Rotation secret memory ownership

Online admin-password and cluster-secret rotation requests own their decoded secret fields in storage that is zeroized when dropped, including rejected rotations and partially decoded requests. Accepted long-lived credentials are copied into guarded memory before these request-owned strings are released. This does not promise complete erasure of an HTTP request: raw body buffers, JSON parser scratch storage and transport-level copies are outside that ownership boundary. No heap-after-free inspection is used to establish the guarantee.

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 reader — every 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.

A pattern property counts as a read of whatever its value touches. An inline pattern property can carry an arbitrary expression, including one that reads the graph:

MATCH (p:Person), (q:Person {age > head([(p)-[:HAS]->(s:Secret) | s.amount])})
RETURN q.name

:Secret and [:HAS] are read here even though neither is named in a MATCH pattern, so both are counted (and authorized) exactly as if they were — for a user granted Person only, this read comes back empty, and the same shape in a CREATE/MERGE pattern property is rejected. Without that, the comparator would work as a value oracle (row present / row absent, one bit per query), and a CREATE (t:Tag {v: <ungranted value>}) would copy ungranted data straight into a label the user can read.

Silent filtering is now OBSERVABLE, without leaking what was filtered (issue #1064). A row-filtered empty read used to be byte-identical over REST to a genuine zero-row result — a single-trusted-backend deployment (this profile) has no RBAC admin watching every response, so it could not tell “no data” from “RBAC quietly filtered this”. If anyone later grants that backend’s own account per-label restrictions (a natural defense-in-depth move), every read touching an ungranted label — or any whole-entity projection, per the callout above — silently drops to empty, and the backend cannot detect it. Two changes close that, without disclosing anything the querying user doesn’t already know from having written their own query (the same soundness argument the empty result itself relies on):

  • The response itself is now distinguishable. REST returns {"filtered": true} instead of the indistinguishable {}; Bolt’s terminal PULL/DISCARD SUCCESS carries a filtered: true metadata key. A genuine zero-row result is completely unchanged either way — no new key, no behavior change for a query that isn’t row-filtered.
  • The durable audit log records it too — a rbac.filtered entry (Decision::Allow, since a permitted-but-filtered read is not a denial), for the specific (user, db) pair, so an operator sees it even if the backend’s own code never checks the marker. Coalesced, not one entry per hit: at this profile’s 99%-read traffic, a misconfigured grant could make FilterEmpty fire on nearly every request — writing one durable entry per hit would put a disk write on the hottest read path in the system and would itself become the dominant routine audit-log entry, crowding out ordinary data.write/data.read under the count-based retention cap (see “Durable audit log” above). The first occurrence for a (user, db) pair is written immediately (so you see the moment filtering started, not after a delay), and repeats within a 5-minute window are only counted, flushing one summary entry when the window elapses. A metric, patinadb_rbac_reads_filtered_total{db,user}, increments on every hit unconditionally (an in-process counter, not a disk write, so it carries none of that coalescing trade-off) — alert on a sustained non-zero rate the same way you’d alert on patinadb_auth_root_login_failures_total (issue #1065).

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. Persistence is asynchronous and node-local. The request updates a bounded in-memory ring and enqueues the event for a background writer, which group-commits to the raft_audit tree in <data-dir>/_audit/patinadb.redb. This node-local file has its own redb writer, so audit retention and audit commits do not hold the Raft log’s writer lock. Its native page cache is capped at the smaller of the configured live-file cache and 64 MiB (64 MiB when unset); this is not a total process-memory limit. Shared-device I/O contention can still affect both files. Committed entries survive a restart; a request response does not acknowledge that its audit event has been fsynced. Graceful audit-log shutdown drains the writer, but a hard crash can lose the uncommitted tail. A full writer queue or a storage failure can also prevent durable persistence of an event. The tracing mirror is an export hook, not a guarantee that an external sink received or persisted it; validate your log pipeline’s delivery and retention separately. At first startup, existing audit rows and their retention counter are copied from _raft in bounded durable batches before the audit writer starts. A completion marker is persisted last; an interrupted copy can resume on restart. The legacy source remains unchanged, and a completed import is not repeated: retention must not resurrect old entries. Include _audit in stopped-node file backups or export each node’s trail; graph backups and Raft snapshots do not include this node-local history. Downgrading to a server that writes audit records in _raft creates a separate continuation, not an automatic two-way migration; preserve both files before changing versions.

Scope: 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 for the engine — which makes full-disk encryption a required production control, not an optional one. See “Encryption at rest” below.

Forensic context: source IP + client origin (issue #1069). Every entry also carries peer_ip (the client’s source address, from REST’s real listener or the accepted Bolt TCP peer — None only in a test harness or an auth-disabled/internal path with no real network context) and an optional origin — a short client-supplied tag naming the specific caller (e.g. "svc-42", a backend instance or pipeline id), read from the reserved origin key (falling back to actor) in REST’s provenance map / Bolt’s tx_metadata. This works independently of Anamnesis provenance being enabled for the target database — Anamnesis only projects that same map into the graph as an opt-in feature; the audit log records it whenever the client sends it, because “which backend instance made this call” is precisely the question a single-principal deployment needs answered (RBAC alone answers only “which role”, and in this profile every call carries the same role). A client-supplied origin is capped at 200 bytes (truncated, never rejected — a truncated-but-present origin still has forensic value) so a stray huge string can’t inflate one audit entry without bound. Old records (persisted before this field existed) simply decode with peer_ip/ origin absent — no migration needed. GET /mgmt/audit returns both fields as JSON strings when present and null when absent (#1355). The endpoint retains its admin-only and Pro/Enterprise entitlement requirements.

Retention is class-aware (issue #1069), not purely count-based. Every denial, plus ddl.user/ddl.admin, any rotate-*/retire-* action, and unlock-user (issue #1071 review follow-up — who cleared a locked-out account’s failed-attempt window, and when, is exactly this class of event), is exempt from the count-based cap — these are the rare, forensically load-bearing events an audit log exists FOR, and at this profile’s ~10 writes/s a purely count-based cap (the pre-#1069 behavior) would let routine data.write entries push a week-old denial out of a 100,000-entry window within hours. --audit-max-entries (default 100,000, 0 = unlimited) now bounds only ROUTINE entries (data.write, data.read, and the DDL classes not named above); a denial or a named DDL/rotation event is retained until the tree itself is dropped (e.g. a fresh install), not pruned by write volume. If your deployment produces an unusual volume of denials specifically (an active attack, or a badly-configured client hammering a wrong password), that volume is now itself the signal to act on — see the patinadb-auth alert group for the analogous root-login-failure counter (issue #1065).

Protected means long-lived, not immortal — a review finding, closed before merge. Making protected entries exempt from the routine cap raises an obvious follow-up: what stops a broken client stuck in a retry loop, or an attacker deliberately drawing denials, from growing the audit tree without bound? --audit-max-protected-entries (default 1,000,000, 0 = unlimited — an explicit opt-out, not the default) is a SEPARATE, much higher, hard safety-net cap on protected entries specifically. Past it, the OLDEST protected entries start giving way — logged loudly (tracing::error!), counted (patinadb_audit_protected_entries_pruned_total), and recorded in the audit trail itself: one audit.retention_loss entry per prune batch (never one per evicted entry — that would be the same unbounded-growth problem in miniature), itself protected, naming how many entries were evicted and the oldest one’s original timestamp — so the loss is visible even to an operator who scrapes neither metrics nor logs. patinadb_audit_protected_entries_total (a gauge, always exported) is the early-warning signal, and the PatinaDBAuditProtectedEntriesGrowing alert fires at 80% of the default cap — weeks before anything is actually evicted in a typical single-backend deployment. Measured (this box, N=20,000 representative Deny entries with realistic username/statement-fingerprint/ peer_ip/origin fields, ALLOCATED disk via blocks()*512 — see audit::tests::bench_protected_entry_disk_cost, #[ignore]): ~416.6 bytes/entry, extrapolating to ~0.39 GiB for the default 1,000,000-entry cap (#1069) — a bound an operator can reason about on disk terms, not a bare number.

Encryption at rest — a REQUIRED operator control

patinaDB stores everything unencrypted on disk. That is a deliberate scope decision, not an oversight — but it shifts a real obligation onto the operator, so it is stated here rather than left implicit.

What sits in the clear under --db:

DataWhereWhy it matters if the file is exfiltrated
argon2 PHC password hashesraft_users redb tree (rbac.rs)argon2 is deliberately slow, but the hashes are offline-crackable — an attacker with the file has unlimited attempts and none of them touch the rate limiter
Graph data (every node, edge, property)the per-database .redb filestenant data, in full
Audit trailraft_audit redb treewho did what, and the literal-collapsed query shapes
Raft lograft_logs treeevery replicated write, including its values

Therefore: run patinaDB on an encrypted volume. LUKS/dm-crypt, a cloud provider’s encrypted block storage, or an encrypted ZFS/APFS dataset all satisfy this. Treat it as you would treat setting --auth-password: the product is not secure without it, and no engine-side setting substitutes for it.

Two limits worth stating plainly, because full-disk encryption is often assumed to do more than it does:

  • It protects a volume at rest, not a running node. Once mounted, every file is readable by anything with filesystem access. It defends against a stolen disk, a decommissioned drive, or a snapshot copied out of a cloud account — not against an attacker who already has a shell on the host.
  • Backups inherit nothing. A /mgmt/snapshot dump or a patinadb backup file is plain bytes wherever it lands. Encrypt the destination too, and treat a backup file as exactly as sensitive as the database it came from.

Engine-side at-rest encryption (an encrypted storage backend, or key-wrapping for the credential tree specifically) remains out of scope; if you need key-management guarantees the OS layer cannot give, that is the gap to raise.

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. Every one of these decoders has a libFuzzer harness in the repository (crates/patinadb-raft/fuzz: packstream_unpack, bolt_request, snapshot_header; crates/patinadb/fuzz: the Cypher parser, the snapshot record stream, and the engram-history stream; crates/patinadb-io/fuzz: the CSV/Parquet/Arrow-IPC import readers). PR CI builds every target as a compile gate against bit-rot (the harnesses live in workspace-excluded crates that cargo build --workspace never touches), and a scheduled workflow (.github/workflows/fuzz.yml, weekly plus manual dispatch) runs each target for a bounded five minutes against its checked-in seed corpus, uploading any crash reproducer as a build artifact. This is bounded CI fuzzing, not a continuous-fuzzing infrastructure like OSS-Fuzz — a deep, hours-long campaign is still a manual cargo +nightly fuzz run <target> from the matching crates/*/fuzz directory.

Unauthenticated Bolt connection lifecycle

An unauthenticated attacker can open a Bolt socket and speak just enough protocol to keep it alive without ever supplying credentials. Three independent, coordinated bounds close every phase of that lifecycle end to end, with no gap between them:

  1. Accept → version handshake (--bolt-handshake-timeout-secs, default 10): a socket that sends nothing, or stalls partway through the 20-byte Bolt version-negotiation preamble (or, on the TLS/WebSocket transports, the TLS ClientHello or the HTTP upgrade), is dropped after this deadline.
  2. Version handshake → first successful authentication (--bolt-auth-timeout-secs, default 30, issue #730): a hard deadline, armed once as a fixed instant the moment the version handshake completes. This closes a residual the idle-read timeout below cannot: a bare HELLO with no credentials, a TELEMETRY message, and any unrecognized message are all answered without authentication — each resets an idle clock, so a client that never intends to authenticate could otherwise dribble one such message forever and keep the connection (and its file descriptor and tokio task) alive indefinitely. Unlike --bolt-idle-timeout-secs, no volume or cadence of pre-auth traffic can push this deadline out; once the connection authenticates it is never checked again.
  3. After authentication (--bolt-idle-timeout-secs, default 300; plus the transaction- and stream-specific reapers, --idle-tx-timeout-secs and --stream-pull-timeout-secs): the usual message-resettable idle clock, appropriate once a real session is established.

--max-bolt-connections sits underneath all three: its permit is acquired only at the instant a connection first authenticates (BoltConn::admit_connection), never at TCP accept, so a flood of connections in any of the unauthenticated phases above can never spend the budget meant for real, authenticated clients — it is bounded purely in time by (1) and (2), regardless of how many are opened at once.

Rejected for this specific gap: a separate semaphore-based cap on “handshake-complete-but-not-authenticated” connections (it does not, by itself, stop a connection within the cap from living forever, and needs new plumbing spanning the listener and an HTTP-mirror question this repository explicitly treats as a follow-on); and reusing the brute-force auth throttle (--auth-max-attempts-per-min) to rate-limit pre-auth traffic — that limiter is keyed on failed authentication attempts, and this exploit never calls authenticate() at all, so it is the wrong tool.

AI-agent access (MCP)

patinaDB no longer ships an MCP server. The patinadb-mcp crate was removed in 2026-07 because its default mode had no filesystem gate: an agent could read and write arbitrary host files through the query tool. Rather than patch a component that was not yet on the supported path, it was withdrawn.

If you need agent access today, put your own adapter in front of the Bolt endpoint and give it a dedicated account whose rights you control with the “Users & roles (RBAC)” mechanisms above — a read-only agent gets a Reader role — and note that the “Cypher-driven file I/O” sandbox below independently confines LOAD CSV / patinadb.export.* regardless of what the agent asks for. A replacement built over Bolt is planned; there is no supported MCP surface in the meantime.

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 + global Admin 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.

A peer RPC carries the cluster secret and nothing else (issue #696). Up to that fix every /raft/{append,vote,snapshot} request also carried Authorization: Basic base64(user:root-password) — dead weight since issue #385 made the receiver read only the cluster secret, but with peer TLS off it put the root password on the private network several times a second per peer. So a passive observer on that segment, or an attacker who got traffic for a peer address, recovered full admin rather than just the cluster secret. The Basic header is no longer sent on any peer RPC; the two secrets are now genuinely independent on the wire as well as in configuration.

This does not make peer TLS optional. The cluster secret is still a bearer token in cleartext without it, and it is the boundary for the whole cluster-internal surface.

Firewalling it concretely

The Bolt port is the one most often left open by accident: unlike the REST port it is rarely fronted by a reverse proxy, so whatever you bind is what the internet gets. Bind it to a private interface and allow only your application hosts.

The safest binding is loopback plus a TLS-terminating proxy (see Reverse-proxy termination above). Where drivers must reach the node directly, bind a private address — never 0.0.0.0 on a host with a public interface:

--addr 10.0.1.11:21001 \
--bolt-addr 10.0.1.11:7687

Then restrict both ports. With nftables (adjust the subnets):

table inet patinadb {
  set app_hosts   { type ipv4_addr; flags interval; elements = { 10.0.2.0/24 } }
  set cluster_peers { type ipv4_addr; elements = { 10.0.1.11, 10.0.1.12, 10.0.1.13 } }

  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif lo accept

    # Bolt: application hosts only.
    ip saddr @app_hosts   tcp dport 7687  accept
    # REST + peer RPC share this port: allow app hosts AND cluster peers.
    ip saddr @app_hosts   tcp dport 21001 accept
    ip saddr @cluster_peers tcp dport 21001 accept

    # Everything else — including the public internet — is dropped.
  }
}

On a cloud provider, express the same thing as security groups: one group allowing 7687 and 21001 from the application tier, and a second allowing 21001 only from the cluster’s own group (peer RPC). Do not put 0.0.0.0/0 on either.

Because REST and peer RPC share a port, a rule that opens 21001 to your application tier also exposes /raft/* to it. That surface is protected by the cluster secret (a missing or wrong X-Cluster-Secret header is rejected), but treat the secret as a credential of the same weight as the admin password, and prefer network isolation on top rather than relying on it alone.

Verify from outside the allowed range — an unreachable port is the only proof that matters:

# From a host that should NOT have access: both must fail/time out.
nc -zv -w3 <node> 7687
nc -zv -w3 <node> 21001

RBAC changes propagate through Raft (eventual on followers)

Existing Bolt sessions for directory users are also checked against that user’s generation before each authenticated message, including PULL and COMMIT (#1265). Password, role or grant changes, user deletion/recreation and snapshot directory replacement invalidate the old session. Its pending cursor and uncommitted transaction are discarded; reconnect and authenticate again. Changes to another user do not invalidate this session. This does not interrupt a message already in flight. Root-admin and auth-disabled sessions are separate from the replicated user directory.

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).
  • Put --db on an encrypted volume, and encrypt wherever backups land (see “Encryption at rest”). Nothing patinaDB stores is encrypted by the engine — hashes, graph data, audit trail and Raft log are all in the clear.
  • 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 and global-Admin-only by default (issue #693) — point the scrape at an Admin credential; 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).

Container deployment

The server image runs as UID/GID 65532:65532 on Distroless. The supplied Compose deployment additionally uses a read-only root filesystem, drops all Linux capabilities, disables privilege escalation and bounds temporary storage and task count. /data remains writable for the database, backups and restore staging. Other output locations need explicit writable mounts; configuration and keys should be mounted read-only. These restrictions require runtime configuration and do not follow automatically from docker run IMAGE. See the repository’s deploy/README.md for the equivalent Docker flags and docs/container-hardening.md for the qualification status.

Separate container roles

The default server image contains only patinadb-raft; the HTTP management API remains available there. The graph browser/Admin panel and offline CLI tools have separate browser and tools Docker targets. Each carries its own dependency notices and SBOM. The server build does not execute npm or depend on Neo4rs. Offline tools need an explicit data mount with the server stopped; do not open a live server directory concurrently. The Browser’s HTTP proxy remains a local, unauthenticated single-user tool: keep its loopback boundary. It is not a public admin authentication gateway. See the deployment README for Linux container invocations and the remaining image qualification gates.

The image verification evidence includes the installed Debian package versions and their original copyright and common-license files, bound to each platform image digest. Missing texts or a package/version mismatch with the runtime SBOM prevent successful verification. Original-text presence alone does not establish license clearance or delivery of corresponding source code.

Docker-only delivery includes the separately licensed OS components. The approved OS profile binds the base digest and exact package/source versions to original copyright and common-license hashes on both platforms. It does not add GPL/LGPL to the application dependency allowlist. Unknown or changed OS inputs stop the gate pending review. Raw scanner unknowns remain recorded alongside the explicit OS review; they are not rewritten as a permissive license.

The Docker build checks every binary’s architecture, GNU loader and dynamic libc/GCC dependencies, rejecting static or unreviewed linkage and alternate search paths. Its runtime-linkage.json records binary hashes and dependencies in the shipped notice bundle. This checks the shared-library assumption; it does not itself deliver the covered sources.

Every published image needs its authenticated corresponding-system-source archive, including the actual builder CA overlay and telemetry dependencies. Preserve the shipped notices and the recipient’s rights to replace a covered LGPL shared library with an interface-compatible modified library and debug that modification. The Docker distribution notice states this limited exception to conflicting proprietary restrictions. It grants no general access to PatinaDB source.

The conservative native-source SBOM also records retained, hash-checked import metadata for ML-KEM, ML-DSA, s2n-bignum and Fiat, and the exact ITT source revision. An import revision identifies provenance; generated files and local adaptations are not represented as an unmodified upstream release or proof of which objects were linked. Remaining original-notice and source-delivery gaps remain explicit.

For a retained Debian source bundle, operators can independently check its signatures and exact image coverage using:

python3 scripts/verify_system_sources.py SOURCE_BUNDLE \
  --keyring /trusted/debian-archive-keys.gpg \
  --image-receipt server=/audit/server/release-evidence.json \
  --image-receipt tools=/audit/tools/release-evidence.json

Choose the trust keyring separately and first generate each external receipt from the actual OCI archive with verify_release_oci.py. Supply one receipt for every variant included in the bundle; each must cover amd64 and arm64. This verifies retained source bytes and image binding. It does not distribute those sources, replace an appropriate recipient-facing source mechanism, or resolve the separate browser original-notice and native-attribution gaps.

To create the distributable source archive from an authenticated local cache:

python3 scripts/build_system_sources.py --cache SOURCE_CACHE \
  --keyring /trusted/debian-archive-keys.gpg \
  --image-receipt server=/audit/server/release-evidence.json \
  --image-receipt tools=/audit/tools/release-evidence.json \
  --output system-sources.tar

The cache contains authentication/original-authentication-report.json, its referenced InRelease/Sources.xz files under authentication/, and the exact source archives under sources/. A previously retained source bundle can serve as this cache. Missing exact versions or invalid signatures/checksums fail; there is no substitution with a newer package. Output must not already exist. Identical inputs produce identical tar bytes. The archive includes its manifest, checksums and offline verifier, but trust keys must be supplied independently. Retain and make the archive accessible through the chosen recipient-facing source-delivery mechanism; running this command alone does not publish it.

To collect the exact sources directly from authenticated Debian repositories, use collect_system_sources.py with the same receipt/keyring/output arguments and repeat --repository HTTPS_BASE=SUITE for each selected source repository. For the current Debian 12 base these are https://deb.debian.org/debian=bookworm, https://deb.debian.org/debian=bookworm-updates and https://security.debian.org/debian-security=bookworm-security. The collector verifies InRelease signatures before trusting index hashes and source locations. It fails if the exact image package versions have left those indexes; use an independently selected signed historical repository or retained cache rather than upgrading sources to unrelated versions. Historical signature verification is an authenticity check, not a security-freshness claim.

The Docker workflow and local docker-build.sh oci|push share collect-release-sources.sh; source collection failure prevents successful completion/publication. The workflow retains the source tar and audit report with its release-evidence artifact. The repository pins original Debian archive public-key bytes and their fingerprints/checksums under scripts/third-party-notices/debian-keys/; rollover co-signatures remain verified. Workflow artifact retention is currently 90 days and access follows repository permissions. It does not replace a durable, recipient-accessible source-delivery mechanism for the intended distribution.

Native components with alternative licenses record an explicit elected_license in the reviewed inventory and generated SPDX metadata. Validation checks that this selection satisfies the original expression, preserves all AND obligations and uses only already allowed terms. Declared expressions and all original notice bytes remain intact. This records the distribution’s choice without asserting that every native provenance or relinking question is resolved.

Upstream MIT declarations (2026-10-03)

The user accepts upstream MIT declarations without maintainer confirmation. Seven reviewed browser dependencies now carry their version-bound package manifest, author metadata, an explicitly labelled SPDX MIT standard template and a declaration explanation. No upstream LICENSE file, copyright holder or year is fabricated. Existing embedded notices remain unchanged. The collector checks the reviewed manifest hash, package identity and MIT election; unknown packages and changed declarations still fail closed. Fresh browser OCI evidence is required; earlier server/tools evidence is retained at its original source.

Identity provider security

OIDC/OAuth validation, SCIM credentials, outbound-network restrictions, local break-glass access, and administrative-restore quarantine are documented in Identity and access management. The implementation currently allows explicit private-network CIDRs while continuing to block metadata endpoints; retain HTTPS and issuer/audience/algorithm restrictions.

Dependency and container license checks

The supply-chain CI inventories all tracked Rust and npm lockfiles, including both embedded frontends and auxiliary tooling. Only approved license terms pass; unknown declarations and failed audits block the aggregate check. It runs for every pull request/main push and daily to detect newly disclosed advisories. Server, browser/admin, tools and telemetry images have separate license/SPDX records for amd64 and arm64, bound to their actual image digests. Basic repository inventory generation does not substitute for image evidence. Branch protection requires the separate documented rollout after the workflow passes on main.

Identity and access management (RFC0037)

IAM combines the existing local account and database-role model with replicated profiles, API keys, optional federated OIDC/OAuth providers, and SCIM-managed identities. The candidate implements D1–D6 of RFC0037, and its bounded source-bound local validation has passed. This chapter describes code behavior, not a release qualification or support claim for any particular IdP; PR CI verification remains.

Edition boundary and migration

Community includes local users, service principals, database roles, direct grants, permission profiles containing Community permissions, and local administration. Professional and Enterprise include the supported fine-grained label, relationship-type, and relationship-property permissions. Enterprise adds OIDC/OAuth federation, external identities, workload tokens, SCIM and provider administration. A profile is gated by the permissions it contains. Reducing access remains available after expiry or downgrade: revoke grants, unassign profiles or bindings, suspend or remove principals, and disable providers. Stored restrictions continue to apply. License checks block only operations that would add or widen access.

Existing local accounts and their direct role/grant semantics remain usable; profile mode is explicit and does not silently replace direct grants. Configure the local root administrator as break-glass access before enabling a provider. Once provider configuration has been used, a replicated sticky enforcement marker prevents deleting the final provider from reopening anonymous admin access. The marker survives reopen, Raft snapshot/install, and administrative restore/import; only actual provider configuration sets it. Do not treat an empty provider list as permission to turn authentication off.

Raft peer protocol 19 introduces IAM commands and snapshot header state; the current snapshot format constant is 6. Every cluster member must support the IAM protocol before IAM state can be restored or used. A portable/offline administrative restore merges IAM records into the target and imports providers disabled and quarantined; an administrator must reconcile and explicitly release them before re-enabling. Local users, service accounts, profile state, external records and tombstones are merged. This is distinct from an exact Raft snapshot install, which installs the snapshot’s replicated state at its cut.

Local principals and profiles

A local interactive user authenticates with a password; a service principal uses API keys. API-key plaintext is shown only at creation, uses the pdbk_<16-hex-id>_<base64url-secret> form, and only its SHA-256 verifier is stored. Up to 16 API keys are accepted per service account. Keys can expire and be individually revoked. A SCIM credential uses the analogous pdbs_... form; only its verifier is stored, plaintext is shown once, and up to two credentials per provider support rotation.

Direct access mode preserves the existing role and per-database grant model. Profile mode composes the assigned flat profiles with closed database defaults; profile permissions do not merge into direct grants. Local principals can be suspended, and account authorization changes are replicated. Management list endpoints use offset and limit, defaulting to 50 and capped at 100; responses include total, offset and limit. SCIM list responses cap a page at 200.

A profile-managed account ignores its direct role and grants. GRANT, REVOKE and ALTER USER … SET ROLE against it, and role/db_roles in a management edit, are refused with 409 and an explanation instead of reporting a change that has no effect; change its profiles, or switch it to direct mode first. An administrator editing their own account’s global role, access mode or (profile mode) profile assignments must send "confirm_self_change": true; the configured root stays the recovery path. User-management statements that contain a password are shown in SHOW TRANSACTIONS / /mgmt/transactions only as their literal-free fingerprint.

Federation and SCIM

OIDC/OAuth access tokens are verified against configured issuers, audiences, allowed JWS algorithms and JWKS keys. Defaults in the model include subject claim sub, token types at+jwt and application/at+jwt, groups claim groups, JIT enabled for token-claims membership, 60 seconds clock skew, one-hour maximum token age, 5-minute to 24-hour JWKS cache bounds, 30-second unknown-key refresh, one-day maximum JWKS staleness, 512 groups and a 16-KiB token limit. These are configurable implementation defaults, not a statement that every provider’s tokens or claims are supported.

The two membership modes are explicit. Token-claims mode can create a principal just in time and uses verified token membership. Its default groups claim is groups. SCIM-managed mode uses SCIM as the authority for account activation and group membership; its default token correlation claim is sub. Token groups do not restore membership or reactivate a disabled account. Deletion/deactivation does not resurrect prior access on re-creation. Pre-JIT tombstones can block first login. Provider records support optional workload-client allowlists and per-provider strict freshness; strict freshness is off by default and applies to external principals only.

A provider with a workload policy is workload-only unless an explicit signed human-token discriminator is configured. For example:

"workload": {
  "client_claim": "azp",
  "allowed_clients": ["etl-client"],
  "human_token": {"claim": "idtyp", "value": "user"}
}

Use the actual claim and value your IdP guarantees for human tokens; the example is not a universal IdP mapping. Omit human_token for workload-only providers. Existing mixed-provider configurations with workload must add this explicit human discriminator before human logins can continue. Every other token must carry a string client claim on the allowlist, regardless of whether its subject equals the client identifier. Missing or malformed client claims are rejected. Providers without workload retain their human-token mode. An external identity is scoped by provider, subject and kind: a workload token never resolves to a principal created for a human token with the same subject, and vice versa. In scim_managed mode only SCIM-provisioned principals can sign in; a principal left over from earlier token-claims JIT cannot. Switching an enabled provider to scim_managed needs the same recent break-glass verification as enabling one.

Tokens carrying the ID-token-only claims at_hash or c_hash, or a typ: "ID" claim, are refused even when a provider accepts a generic JWT header type. Prefer RFC 9068 at+jwt tokens; if an issuer can only send JWT, list it in token_types and give the Patina API its own audience, distinct from the Console’s client ID. exp, nbf and iat accept integer or fractional NumericDates; any other value is malformed. An overage or incomplete group list (hasgroups, _claim_names.groups) grants no group-binding access and logs a warning naming the provider and principal.

Key refreshes are single-flight per provider and run outside the cache lock: a verification whose key is cached and still usable never waits for a slow or unreachable IdP. At most 64 verifications run at once; a request that cannot start within 5 seconds fails as “identity provider unavailable”. These are safety bounds, not measured capacity figures. unknown_kid_refresh_secs has a one-second floor. jku, x5u and embedded jwk headers are ignored and never fetched. IPv6 addresses that embed or translate to IPv4 (mapped, compatible, NAT64 64:ff9b::/96, 6to4 2002::/16, Teredo) are classified as that IPv4 address by the outbound address check.

Failed JWKS fetches, including an empty initial cache, are retried no more often than unknown_kid_refresh_secs (default 30 seconds, minimum one second for failure backoff). Provider revision changes invalidate this retry state. Each request still checks maximum staleness measured from the last successful fetch; backoff never extends that deadline. Recovery after an outage may wait for the retry window.

Outbound IdP calls require HTTPS by default. Private, loopback or link-local network destinations require explicit CIDR allowlisting; cloud metadata endpoints remain blocked. The console has separate explicit switches for allowlisted private networks, custom CA material and development-only HTTP. Do not enable insecure HTTP in production. The console requires a configured public HTTPS origin outside loopback development, and its OIDC callback must match that origin. Without --sessions, all modes are loopback-only and reject --public-origin; shared credentials must not be exposed through a public Console listener.

Bearer tokens on REST and Bolt

REST clients send Authorization: Bearer <token>; an invalid bearer never falls back to Basic or anonymous access. Bolt dispatches the scheme explicitly: basic (or absent), bearer and none; any other value is refused as Neo.ClientError.Security.Unauthorized. For bearer, the caller-supplied principal is ignored. An expired token is Neo.ClientError.Security.TokenExpired on REST (with WWW-Authenticate: Bearer error="invalid_token", error_description="token expired"), on Bolt 5.1+ LOGON, on pre-5.1 HELLO (Bolt 4.4/5.0), and on later messages of a retained session, where an open transaction is rolled back. A failed LOGON leaves the connection open for a fresh LOGON; an unreachable IdP is Neo.TransientError.General.DatabaseUnavailable.

With the official Python driver this means neo4j.bearer_auth(token) for a static token (the driver raises neo4j.exceptions.TokenExpired) and neo4j.auth_management.AuthManagers.bearer(provider) for automatic refresh: the driver asks the provider for a new token after the server reports TokenExpired, and managed transactions retry. Verified locally with driver 6.2.0 and 5.28.2 against Keycloak 26.8.1 (see the recipe below); other drivers are unqualified.

Keycloak recipe

The repository harness tests/iam-keycloak/ applies this recipe to a real Keycloak container and checks it (Keycloak 26.8.1, 2026-10-21; see the RFC implementation status). It qualifies only that version and configuration.

  1. In a realm, create a bearer-only client patina-api (the resource audience) and add these protocol mappers to every client that requests Patina tokens: an Audience mapper adding patina-api to access tokens only; a Group Membership mapper with claim groups and full path off; and, for application roles, a User Client Role mapper for client patina-api with claim patina_roles (multivalued).
  2. Enable Use “at+jwt” as access token header type (client attribute access.token.header.type.rfc9068) on those clients, so tokens are RFC 9068.
  3. For workloads, create a confidential client with service accounts only (client credentials) and put its service-account user in the groups it needs.
  4. For humans, mark tokens with a signed discriminator, for example a Hardcoded claim mapper patina_principal=human on the human-facing clients only (never on a client with service accounts enabled).
  5. For the Console, a confidential client with the standard flow, PKCE S256 and the exact redirect URI https://<console origin>/auth/oidc/callback.
  6. In Patina (PUT /mgmt/iam/providers/keycloak): issuer https://<host>/realms/<realm>, audiences: ["patina-api"], algorithms: ["RS256"], token-claims membership with groups_claim: "groups" and roles_claim: "patina_roles", workload: {"client_claim": "azp", "allowed_clients": [...], "human_token": {"claim": "patina_principal", "value": "human"}}, and outbound.ca_pem/allowed_networks for a private Keycloak. Then bind group names or roles to profiles.

Caveats: Keycloak’s group-membership mapper emits group names, not stable IDs, so renaming a Keycloak group changes the claim; prefer client roles for privileged mappings. Group changes reach Patina only with new tokens (the token-claims boundary); use emergency disable for immediate revocation. Keycloak does not provision outbound SCIM, so this recipe is token-claims mode. Microsoft Entra ID and Okta recipes are not yet written or qualified.

SCIM service provider

/scim/v2/{provider} accepts only that provider’s pdbs_… credential and only while the provider is enabled, unquarantined and scim_managed. It supports:

  • Discovery: ServiceProviderConfig, ResourceTypes, ResourceTypes/{id}, Schemas, Schemas/{id}. Any other path under the base answers a SCIM error (404) behind the same credential.
  • Users and Groups: GET/list, POST, PUT, PATCH, DELETE; startIndex/count (at most 200), ETags and If-Match; attributes and excludedAttributes (top-level names; schemas, id and meta are always returned).
  • Filters: only attr eq "value" on userName/externalId (users) and displayName/externalId (groups); values match case-sensitively. Other filters are 400 invalidFilter. Bulk, sorting and POST /.search are unsupported.
  • User PATCH: add/replace of active, displayName, userName, externalId (same value only), emails, or a pathless object; remove of displayName or emails.
  • Group PATCH: operations apply in order; add/remove/replace of members (a remove without a value removes all members), remove members[value eq "…"], add/replace of displayName and externalId (same value once set).

Identity rules: externalId is immutable once provisioned, for users (the token correlation key) and groups (the binding key); changing it is 400 mutability, so delete and re-create instead. active changes only when an explicit boolean (or the string "true"/"false") is sent; omitting it keeps the current state, and removing it is refused. Deactivation removes membership effect and local profile assignments; reactivation restores only the identity. Delete + re-create yields a new principal without memberships or assignments, and an administrator’s emergency disable carries over to it. Group members must be provisioned users of the same provider: an unknown member is a retryable 409, a member naming a group is a permanent 400 (no nested groups); roles/entitlements never become grants.

Checked locally with the independent scim2-tester 0.5.2 client (tests/iam-scim/): 47 checks pass; the remaining ones are the deviations above (no /.search, a single primary email, active not removable, unknown member references). This is not a qualification of Entra ID or Okta provisioning.

Console sessions and revocation

Console sessions are node-local and in-memory, with defaults of 30-minute idle timeout, 12-hour absolute lifetime, 5-minute recent-authentication window and a 10,000-session cap. One principal holds at most 16 concurrent sessions; its oldest is retired first, and the global cap evicts the oldest-created session, so repeated sign-ins by one account cannot push other operators out (both are safety bounds, not measured figures). They use secure host cookies outside explicit loopback development mode. The console uses per-session upstream credentials; configuring a shared upstream credential is rejected in session mode. During activity, the session idle, absolute and recent-authentication windows can be configured with --session-idle-secs (default 1800), --session-absolute-secs (default 43200) and --session-recent-auth-secs (default 300). Every protected request re-verifies upstream identity and effective access; logout removes the session without a network check. A changed effective-access fingerprint, including database roles and grants, retires the old session ID and requires a fresh login. Password reset, suspension, credential revocation or identity replacement therefore ends a retained session at its next request. This is not a cluster-wide shared browser session store. Recent authentication is required for sensitive IAM UI actions and all admin POST /query requests, including read queries, because the same endpoint can execute user/role DDL. Reauthenticate after the configured recent-authentication window before using the admin query editor. Non-admin queries retain their normal session lifetime.

A server or identity provider that is unreachable or failing is reported as 503 upstream_unavailable, not as invalid credentials. Organizational sign-in failures return to the Console with a signin_error code that the sign-in panel explains (not_authorized, identity_mismatch, upstream_unavailable, oidc_unavailable, invalid_state, …). An IdP-authenticated user without any Patina mapping gets a session whose effective access grants no database. The Identity & Access view shows the configured break-glass root, when it was last verified and by whom, and offers emergency disable for managed principals.

Public sign-in and existing-session verification have separate 32-request network budgets. Existing sessions wait up to 10 seconds for their budget; a capacity timeout returns 503 without retiring the session. No successful authority check is cached: immediate revocation checks still occur per request. OIDC pending state is held in an authenticated encrypted HttpOnly cookie with a 10-minute absolute lifetime, not a server-side pending-login pool. The IdP must enforce single-use authorization codes; PKCE, nonce and state remain mandatory. Restarting the Console invalidates pending logins. Discovery is coalesced and cached for 5 minutes on success and 10 seconds on failure; outbound validation still applies when using the cached endpoints.

The current candidate revalidates authorization before replayed and live /changes events and after resynchronization. Expiry, revocation, or an unavailable check ends the stream with an SSE error and clears queued events. Targeted expiry and revocation regressions passed in the source-bound local validation. This does not promise instantaneous cluster-wide revocation before a change is committed; PR CI verification remains.

The replicated write fence binds authorized writes to principal and policy versions so revocations ordered before a write are enforced during apply on each replica. Strict freshness is a separate opt-in read barrier for external principals; local users retain the normal replicated-apply contract. These mechanisms do not promise instantaneous propagation before a change commits.

Audit and recovery

IAM state is replicated control-plane state, separate from graph records. Audit entries use the existing audit system; tracing/export is not an audit-delivery guarantee. Recovery procedures and credential rotation remain operator tasks. Use Security for authentication fundamentals and Limitations for current qualification boundaries.

Identity restore and crash recovery

Portable REST restore submits the user records and optional IAM metadata in one ordered authorization-fenced identity command after restoring graph data. It can restore the calling local administrator’s own account; a revocation ordered before this command still rejects it. Protocol 19, valid unique user names and the complete identity command’s entry-size limit are checked before graph writes. An identity bundle that exceeds the entry limit is rejected rather than split across authorization changes. Restoring identity state does not make the whole multi-database graph restore atomic.

Legacy backups without IAM metadata preserve existing local principal IDs, suspensions, profiles and service keys. Backups carrying IAM metadata merge it and quarantine providers as before. Exact Raft snapshot installation remains an authoritative replacement, separate from portable backup restore.

An accepted IAM command stores a digest and its log position atomically with its IAM changes. Crash replay of that exact command completes credential side effects even if the command changed its own authorization version. Another command or log position gets no such exemption. Apply progress is persisted at IAM boundaries before another receipt can replace it; normal graph group commit is unchanged.

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-voter → add-learner → change-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.

Closed (issue #1075). A single-shot POST /mgmt/init {"1":addr1,"2":addr2,"3":addr3} used to call Raft initialize directly and skip the voter-cap check entirely — a Community node could bootstrap a fixed multi-voter set in one call and never touch change-membership again. /mgmt/init now enforces the SAME 409 VoterCapExceeded check change-membership does, refused before initialize ever runs (so a refused attempt leaves the node cleanly uninitialized, free to retry with a valid set). It is still guarded against an even voter count too — 409 unless you pass {"members": {…}, "allow_even": true} (that override affects only the even-voter guard, never the voter cap; see Cluster sizing). Community remains subject to the telemetry / scale / retention caps regardless — it is not a viable production configuration — get a license.

Clocks: a hard precondition for idempotency tokens

If your clients use idempotency tokens (Client retry contract), synchronised clocks are not a nice-to-have. A leader whose clock jumps forward by more than the token retention window moves the dedup watermark permanently into the future — every token stamped afterwards is born expired and the next retry duplicates, with nothing but a WARN in the leader log to say so (issue #1137). Monotony of the watermark means no in-database recovery exists.

Therefore, on every node: run NTP/chrony, and alert on clock skew (your monitoring, not the database’s — the database cannot see skew between machines, only its own stamp). A cluster whose clocks are all equally wrong is unaffected; the dangerous case is skew between leaders larger than the retention window (default 10 minutes).

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 long, random --auth-password, at least 16 characters (the root-admin credential; REST Basic + Bolt LOGON) — issue #1065 exempts this exact username from the failed-attempt rate limiter, so unlike a directory-user password, this one has no per-minute guess cap backing it up; see Authentication for why. 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) — unlike root, that user IS subject to the limiter (correctly: it goes through argon2 and carries real guessable entropy, so it needs the protection root doesn’t). That means it CAN get locked out (idle-timeout churn, a stale cached credential during your own credential rotation — the same shape issue #1065 fixed for root). The recovery path is admin-mediated, not self-service (a locked-out account cannot free itself): have root run POST /mgmt/unlock-user {"username": "..."} to clear the lock without touching the password, or ALTER USER … SET PASSWORD to do both at once (issue #1071 review follow-up). Root can always reach either call, since root is exempt from the limiter it is unlocking someone else out of.
  • 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":…}.

Admin-password rotation runbook (issue #1065 — read this before rotating in a single-backend deployment). A per-principal failed-attempt rate limiter is a total-outage switch in this profile: one backend client means one username, effectively one source, so an idle-timeout reconnect burst that races a naive password swap can exhaust the shared budget and lock the account out, including the new password it was just given. Three things prevent that, layered:

  1. The root-admin username is exempt from the failed-attempt limiter entirely (App::authenticate_throttled) — however many wrong guesses land against it, the response is always a plain 401, never a 429. This is the primary guarantee: the account cannot be locked out, so recovery (including calling /mgmt/rotate-admin-password itself) is always possible. This exemption applies ONLY to the configured root username — directory (CREATE USER) accounts are unaffected and stay fully throttled.
  2. POST /mgmt/rotate-admin-password is dual-accept: the outgoing password stays valid alongside the new one for a grace window, so any client attempt still carrying the old password (a config-propagation lag, or a reconnect that started before the rotation) keeps succeeding instead of getting a 401 mid-propagation.
  3. The same call also clears the admin username’s rate-limiter window (belt-and-braces alongside #1 — harmless, and keeps the invariant true even if the exemption’s scope ever narrows).

A restart during the window is not a special case — read this before you rely on the window at all (#1065 review follow-up R4). Every rotatable secret (Secrets in auth.rs — the current password AND the dual-accept alt_password) lives ONLY in the process’s own memory. Nothing about a rotation is written to disk anywhere. So the moment this process restarts, for ANY reason — a deliberate restart, but just as much a crash, an OOM-kill, a redeploy, a Kubernetes pod reschedule — the node reconstructs its credentials from scratch, from whatever --auth-password / PATINADB_AUTH_PASSWORD / config file currently says. If you called rotate-admin-password but have not yet updated that external source to match, the node silently reverts to the pre-rotation password the instant it restarts: not merely “the new password stops working” but the OLD one starts working again too, with nothing in the process’s own state to show a rotation ever happened. A client that had already switched to the new password gets a wall of 401s that looks like an unrelated outage; a client still on the old one, which you may have been actively trying to retire, quietly starts succeeding again.

This is not something a future release can safely fix by persisting the rotation state. Writing the rotated password (even as an argon2 hash, the directory-user pattern) into the on-disk store would create the ONE thing that has never existed for the root credential: an offline-crackable artifact on disk for the exact secret whose failed-attempt-limiter exemption (mechanism 1 above) is safe only because no such artifact exists. It would also make root’s credential dependent on the database’s own state for the first time — a restore from an older backup would silently reset the password too, the same surprise class moved somewhere harder to see. Given that, the right fix is procedural: make a restart during the window harmless instead of trying to make the window durable.

Runbook — update the config FIRST, then rotate to match. This order makes a mid-window restart a no-op for any client that has already switched, instead of a silent regression:

  1. Update your backend’s configured password to <new> — before calling the rotate endpoint. The running node still only accepts the OLD password at this point; nothing changes for it yet.
  2. POST /mgmt/rotate-admin-password {"password": "<new>"}, authenticated with the OLD password. Response has "dual_accept": true — both passwords now work. The config on disk and the node’s new primary password are now the SAME value, so a restart from here on reconstructs exactly the state you intended, not the pre-rotation one.
  3. Let/force the backend to reconnect, then confirm it is actually using the new password — e.g. a successful authenticated request from the backend’s own logs/metrics, not just “the deploy finished”. This is the step a runbook that only says “rotate, then retire” skips, and skipping it is what turns retiring into a self-inflicted lockout of a backend that never actually picked up the new password.
  4. Only then, POST /mgmt/retire-admin-password to drop the old password. There is no rush — retiring is optional, since the next rotation drops the old password anyway. If you suspect the OLD password was compromised, retire as soon as step 3 confirms, not before.

With this order, a restart between step 2 and step 4 is harmless for any client that already picked up the new password (config and in-memory state agree — nothing to revert to) and unchanged risk for one that has not (the same window step 3 exists to close). The reordering does not make the window durable — it removes the specific danger of an UNPLANNED restart silently undoing a rotation the operator believed had already taken effect.

Skipping straight to step 4 before step 3 reopens exactly the failure mode this issue is about, for any client that hasn’t yet reconnected — the only difference from the pre-#1065 bug is that the account itself can no longer be rate-limiter-locked (mechanism 1 above); an old, still-configured backend would simply get a hard 401 instead.

If you have NOT set a dedicated --cluster-secret (issue #1065 review follow-up R5): step 3 covers your PEERS too, not just your client. Without --cluster-secret, the effective peer secret for /raft/* RPCs falls back to the admin password — a documented single-credential convenience. During the dual-accept window this fallback now ALSO dual-accepts (both the outgoing and the new admin password work as the peer secret), so a rotation no longer tears a coupled peer straight out of quorum the instant you rotate. But the window still closes at step 4 — retiring drops the OLD admin password everywhere, on the peer path exactly as on the client path. If any peer is still presenting the outgoing password when you retire (because you only confirmed your BACKEND client in step 3, not your other nodes), that peer is refused just as hard as an unconfirmed client would be — except a lost peer looks like a partition or a quorum problem, not an auth problem, and is correspondingly harder to diagnose under pressure. Extend step 3 to cover every peer: confirm each node’s own outbound /raft/* traffic is authenticating cleanly (GET /mgmt/metrics on each node reporting a healthy current_leader, or check each node’s log for peer-auth denials) before retiring on any of them. The dual-accept window moved the danger from “the moment you rotate” to “the moment you retire” — it did not remove it. A deployment with its own dedicated --cluster-secret is unaffected either way: rotate it independently with POST /mgmt/rotate-cluster-secret / POST /mgmt/retire-cluster-secret, on its own schedule, as documented above.

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 and global-Admin-only by default (its db=<name> labels would otherwise let any authenticated per-db Reader enumerate other tenants’ database names — issue #693), so point your Prometheus scrape at an Admin credential; keep it that way, or open it only on a private monitoring network with --insecure-open-metrics (which bypasses both the credential and the role floor). /health, /ready and /version are always open probes. With authentication enabled, the public /version response never contains database names; peers presenting X-Cluster-Secret additionally receive the database-scoped history-floor map used by retention coordination. An explicitly auth-disabled node retains the legacy map because all of its databases are public already.

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.General.TransientError — wrap 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.General.TransientError. Use your driver’s managed transactions — session.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:Counter {key:$id}) SET n.count = n.count + 1", id=node_id)
    with driver.session() as s:
        s.execute_write(bump, node_id)   # auto-retries the transient
    
    # 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:Counter {key:$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.


Measured operating limits (issue #1079, 2026-08-21, 62-GiB dev box, release build, real 3-node in-process cluster, loadavg ~7–9, two runs agreeing to within ~3%). The pipelined-write admission critical section (per-db write_lock: an openraft read-index quorum-RTT barrier, the constraint check, and the preimage build, all run BEFORE the cheap in-flight-conflict check that actually decides admit-or-reject) is a genuine capacity wall — the question was only where it sits relative to a real deployment’s rate.

armNokgoodput/sp50 msp95 ms
disjoint88/849411.716.1
disjoint3232/3244339.761.5
disjoint100100/100465113.2196.4
hot-key88/825819.130.9
hot-key3210/3276122.8130.4
hot-key10010/10023418.3441.4

Disjoint-write ceiling (different rows/keys per writer): ~465–494 writes/s per database. A planned rate of ~10 writes/s is ~2% of this — comfortably not acute. This ceiling is NOT 1/(single-write latency): a single uncontended write takes ~4.3 ms end-to-end, but resolve/propose/ replicate/apply pipeline across concurrent writers, so throughput exceeds the naive inverse-latency figure — only the write_lock-held admission section is genuinely serial (≤ ~2 ms of it).

Hot-key collapse (many writers contending the SAME row) is a different, real finding: throughput FALLS as writers are added — 258/s at N=8 down to 23/s at N=100 (11.5× collapse), and at N=100 only 10 of 100 writers succeed; the other 90 exhaust the retry budget (8 attempts × a fixed 2 ms backoff, no jitter) and are handed a retryable conflict. Every condemned admission still pays the full quorum-RTT + preimage cost under the shared lock before the cheap in-flight check would have rejected it for free — so added contention on one key buys negative throughput, not just diminishing returns. This is exactly the shape the write-skew guard/ sentinel-row pattern above creates on purpose (every conflicting transaction deliberately touches the SAME AccountGroup/guard node) — the pattern is still correct and still the right fix for a genuine multi-row invariant, but driving it at high concurrency on one guard row is the concrete case this ceiling applies to, not an abstract one.

At the ~10 writes/s rate this measurement was taken for, the hot-key collapse is unreachable (expected concurrency on any single key ≈ 0.02 at a ~2 ms serial section — i.e. two writers landing on the same key at the same instant is a ~2% event, and the collapse needs sustained concurrent contention, not an occasional overlap). It stays a documented limit, not a fix — the fix direction (cheap in-flight check BEFORE the quorum-RTT/preimage cost, then retry jitter) is already known and correctly ordered in issue #1079 for whenever a workload’s key distribution changes this calculus. This depends on the write workload NOT concentrating repeated writes on a small set of hot keys/rows (a shared counter, a single tenant-root node touched by every write, or the guard-row pattern above used at real concurrency) — if your workload does that by design, re-run the bench (cargo test --release -p patinadb-raft --test pipeline_admission_bench -- --ignored --nocapture --test-threads=1) against your actual key-contention shape before trusting “not acute” for it.


5. Verify (go-live smoke checklist)

Deployment rehearsal

Before the manual checklist below, run the automated dress rehearsal:

scripts/deployment-rehearsal.sh          # add --keep to retain logs

It boots a real 3-node cluster (real processes, real HTTP, real kill -9, real backup files) and drives the scenarios that actually bite in production: the cluster forms and elects a leader; unauthenticated access is refused; a write replicates to every node; errors carry a machine-readable retry decision; the cluster survives losing its leader to SIGKILL without losing an acknowledged write; a backup restores into a fresh node; and the killed node rejoins and catches up. Exit code 0 is the verdict.

This is a level above the in-process integration tests: those verify the engine, this verifies the deployment. Run it before launch and after any infrastructure change.

Two things it deliberately does not do. It cannot test your environment — network policy, TLS certificates, backup destination — so it prints a checklist of exactly those at the end. And its retry-contract scenario only proves the server’s half (that every error carries retryable); the client half is yours, see Client Retry Contract.

If you run it without a licence, the backup scenario falls back to the HEAD-state backup and says so: point-in-time recovery (?history=true) is a Pro/Enterprise feature and is refused with a 403 on Community.

Manual smoke checks

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 retry — only 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.General.TransientError 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 + constraints + compound / full-text / vector / point / edge-sorted indexes + WASM functions + tags + RBAC users + the anamnesis flag).

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).

You do not need to quiesce writes. A backup is a consistent cut: every database’s HEAD label, its graph and its engram history are read from one pinned point in time, so a restored backup satisfies the same reconstruct(HEAD) == live graph invariant the source did, and its AS OF timeline is monotone — no matter how many writes land while the backup streams. Writes are briefly serialized only while that point is pinned (one O(1) pin per database), never for the duration of the stream: measured under issue #1105 on a 62 GiB/NVMe box, a 212 MB PITR backup taking 1.73 s moved a concurrent writer’s median latency from 3116 µs to 3333 µs (+7%); no stall resembling the backup’s own duration appeared. (That box was shared, so the measurement is honest about the median and about the absence of a stall — it is not precise enough to quote a tail-latency figure.) (Before #1105 the three parts were read at three different instants and a backup taken under load was silently torn — if you are on an older build, quiesce writes for the duration of the export.)

Watch the backup age. Every successfully completed backup stream (fully downloaded — an aborted download does not count) is stamped node-locally and exported as the Prometheus gauge patinadb_backup_last_success_timestamp_seconds (persisted across restarts; the series is absent until the first backup completes). The shipped alert rules PatinaDBBackupStale / PatinaDBBackupNeverRecorded page when the newest cluster-wide backup is older than your RPO or no backup ever finished — the “backup cron silently stopped” failure mode. See deploy/README.md → Backup & disaster recovery.

Restore

# Restore into a FRESHLY BOOTSTRAPPED, EMPTY cluster (leader). A non-empty
# target is refused (409) — and that refusal is the behaviour you want.
curl -u neo4j:"$PW" -X POST 'http://<leader>:21001/mgmt/restore' \
  -H 'content-type: application/json' --data-binary @patinadb-backup.json

Restore is additive, not a wipe — and ?force=true does not change that. /mgmt/restore replays the backup’s operations on top of whatever the target already holds; it never deletes existing data. The ?force=true flag only lifts the non-empty-target refusal so the restore is allowed to merge into a populated database — it is not “replace what’s there”. Restoring over a live, corrupted cluster therefore merges the backup into the corruption instead of replacing it. Always restore onto a freshly bootstrapped, empty cluster (see The whole cluster is lost under Recovery scenarios below and the Disaster Recovery chapter). This is stricter still for a ?history=true (PITR) backup: installing the history requires a pristine target — a database that has data or a timeline of its own is refused, with or without ?force=true.

Then verify with the shipped scripts/verify-restore.sh (readiness + node/edge counts + an optional AS OF probe), or re-run the §5 checklist.

PITR restore is replicated on peer protocol 21. Restore once through the leader after every member confirms support. Encoded history travels in bounded blocks and each applying replica verifies it before publication. Wait for followers to apply the restore and for newly joined learners to complete history backfill, then verify time-travel reads on each node. Older or unconfirmed clusters refuse the history restore before graph writes. Disaster Recovery describes disk/memory costs and the graph-only rollback boundary.

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). Prefer the live path: read the good state AS OF a pre-incident engram/tag and promote it back to HEAD with CALL patinadb.restore('<engram-id>') — no rebuild, no backup file, minutes of RTO (see Disaster Recovery → Bad write). If the bad write is older than the history-retention window (30 days on Community, unlimited on Pro/Enterprise), that path is gone — fall back to a ?history=true backup that predates the incident, restored onto a fresh, empty cluster as below. Do not restore over the live corrupted cluster: restore is additive, so you would merge the good backup into the bad state.

  • The whole cluster is lost (all hosts/disks gone). Rebuild from your portable backup onto fresh, empty nodes:

    1. Provision new hosts; start node 1 with --bootstrap on an empty data directory (mandatory — restore is additive and a PITR restore refuses a non-pristine target outright).
    2. Copy the backup file onto the new node and restore it: POST /mgmt/restore --data-binary @patinadb-backup.json (no ?force — the target is empty).
    3. Add the remaining nodes as learners and promote them to voters; they pick up the restored HEAD graph via normal Raft replication.
    4. If you restored a ?history=true backup: the time-travel timeline is installed only on the node that ran the restore — re-run the restore on every node you need AS OF history on, or route time-travel reads to that node.
    5. Verify (counts, a known query, an AS OF probe for PITR backups) before repointing clients.

    The full step-by-step runbook, including the majority-lost case and the RPO consequences of your backup cadence, is in Disaster Recovery → Whole cluster lost.

The full disaster-recovery runbook — what each backup mechanism covers, the backup-cadence/RPO trade-off, per-failure RPO/RTO tables (including the majority-of-voters-lost case), the exact recovery procedures, and the post-restore verification checklist — is the Disaster Recovery chapter of this manual.


7. Monitoring & alerting

Every node exports Prometheus metrics at GET /metrics (auth-gated and global-Admin-only by default — the scrape needs an Admin credential). 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 /healthLiveness — 200 whenever the process is up. Use for K8s livenessProbe.
GET /readyReadiness — 200 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, no_quorum (this node leads but a quorum has not acknowledged it for over 5s, so it cannot commit — issue #1106), 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, patinadb_entitlement_usage_ratio{axis} / _limit{axis}, and patinadb_resource_cap_rejections_total{cap} (issue #1153 — see below). 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 (patinadb-cluster.json, title “patinaDB Cluster”). For the full field reference see the “Metrics reference” table in deploy/README.md.

Watch the resource caps (patinadb_resource_cap_rejections_total)

patinaDB refuses a query that would exceed a configured resource bound rather than swapping or dying: the message names the knob to raise, and the error is a deterministic Neo.ClientError.Statement.ResourceExhausted (retryable: false — retrying changes nothing). Your client is told exactly what happened. You were not: until issue #1153 a cap hit reached /metrics only as a generic patinadb_queries_total{outcome="error"}, indistinguishable from a syntax error, so nothing showed that a workload keeps meeting a bound.

One series per cap, labelled with the environment variable that raises it, because the caps call for different actions:

cap labelwhat a rising rate means
PATINADB_CARTESIAN_CAPa query matches two patterns with no shared binding or relationship. Almost always a query-authoring mistake — add a join predicate rather than raising the cap.
PATINADB_MAX_AGG_ROWSa hash join, GROUP BY, sort or collect() would materialise more rows than the bound. Add a LIMIT or a more selective predicate; raise the cap only if the workload genuinely needs that many rows in memory at once.
PATINADB_MAX_CAPTURE_OPSone statement’s mutation is too large (a broad DETACH DELETE or whole-graph SET). Split it into bounded chunks.
PATINADB_MAX_UPSERT_ELEMENTSa bulk subgraph upsert was handed too many elements at once — chunk the load (see Bulk upsert).
PATINADB_MAX_LABELS, PATINADB_MAX_LABEL_INTERNSthe one to alert on. Here it is not a query that fails but the schema registry filling up, and once it is full the database stops accepting new labels or identifiers. A slow climb means schema churn (a distinct label per entity, say) — you want to see this approaching, not arriving.

The counters are monotonic since process start, so alert on the rate(). There is no shipped rule for them yet: what counts as “too often” depends on your workload, and a rule with a made-up threshold is worse than none.

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 (node-local recent ring view backed by asynchronous durable storage; see audit caveats). 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.

    Two different growth sources, budget both separately (issue #1098). “Volume grows without bound” above is about the delta history (one small record per write) — but every DEFAULT_SNAPSHOT_INTERVAL (50) commits, patinaDB ALSO takes a periodic full-graph snapshot (_engram_snapshots/*.snap, a complete copy of the graph at that point, used to bound AS OF/reconstruct replay depth). These snapshots are governed by a separate knob, PATINADB_MAX_SNAPSHOTS (unset/0 = unlimited — but the history-retention squash described below does not prune them: it only removes snapshots older than its cutoff, so within the retention window they still accumulate one file every ~50 commits). At a steady 10 writes/s that is a full-graph copy roughly every 5 seconds — measured (2026-08-20, Dataset on origin/main): 500 commits → 10 .snap files; 2000 commits → 40 files, 5.0 MiB. The corresponding TiB/day figure for a large graph is a calculation from that measured cadence, not itself measured — extrapolating (commits / 50) x graph-snapshot-size per day; budget disk against your own graph’s snapshot size, not just the delta-history rate quoted above. Pruning is strictly safety-preserving (AS OF/reconstruct of every engram stays byte-identical; only reconstruction whose nearest snapshot was pruned falls back to a longer delta replay — a performance cost, never a correctness or availability one), so bounding it costs nothing but occasional extra replay depth. The server binary defaults PATINADB_MAX_SNAPSHOTS=16 when you don’t set it (embedded/CLI keep the unlimited default — set it explicitly there if you want the same bound); raise it if you need a deeper local replay-speed cushion, or set 0 to opt back into unlimited .snap retention.

    The third line item: what a rejoining node has to transfer (issue #1099). RFC-0025 update (#1251, PR #1259): on a cluster eligible for physical v5, onboarding uses a state-only checkpoint including indexes plus the anchor history tail. Older history is backfilled separately. The historical v3 measurements below describe the earlier logical format, not current v5 wire size or onboarding throughput. A prepared checkpoint survives restart; its build and receiver installation still consume disk and I/O. Track current state catch-up separately from historical completeness.

    The two sources above are about disk on a running node. A Raft snapshot is a separate artifact — the file a rejoining or freshly bootstrapped node pulls — and openraft builds one every 5000 log entries, per node.

    It used to carry the periodic .snap graph-snapshot bodies as well, so its size tracked whatever PATINADB_MAX_SNAPSHOTS allowed to accumulate. Since #1099 it does not: those bodies are a reconstruction cache the receiving node rebuilds from the metadata and deltas it does get, so what a snapshot ships is one copy of the graph plus the change log. Measured on a 60 000-vertex fixture with .snap retention unlimited (the pre-#1098 default, which is still the embedded/CLI default): a further 400 commits over an unchanged graph used to add 4.7 MB to every snapshot and now add 69 kB. With the server’s PATINADB_MAX_SNAPSHOTS=16 the old cost was bounded rather than unbounded — at most ~16 full-graph copies per snapshot — so read that 4.7 MB as the unbounded-retention case and scale it to your own bound.

    The trade is that a first AS OF into a region the newly-bootstrapped node has not cached replays deltas instead of loading a cached snapshot — slower once, never wrong.

    The receive cap is not a sizing prerequisite (issue #1246). PATINADB_MAX_SNAPSHOT_BYTES (default 8 GiB) windows a single upload attempt, not the snapshot: a snapshot larger than the cap — measured in the #1145 rejoin drill: a 71 GiB graph yields a ~19.5 GiB compressed snapshot — installs over several probe/resume attempts. You do not have to raise the cap to fit your graph. What you SHOULD alert on is patinadb_raft_snapshot_receive_rejected_total{reason="over_cap"} climbing without the follower catching up: before #1246 that exact shape was a silent livelock (166 aborts, ~1.45 TB retransmitted, one node that never rejoined), and any regression of it is only visible in that metric.

    Do not extrapolate any of these figures: they depend on your graph size, your commit rate and your PATINADB_MAX_SNAPSHOTS together. Size each line item from your own measured growth, alert on patinadb_data_dir_available_bytes, and include snapshot transfer time in your expectations for how long a node takes to rejoin. See PATINADB_SNAPSHOT_LOGS_SINCE_LAST in Configuration for how often a build happens.

  • 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.

CALL patinadb.engrams() is unbounded BY DEFAULT (issue #1114). With no arguments it returns the ENTIRE engram history in one response — exactly as unbounded as the retention window above. At the recommended unlimited-retention profile (~10 writes/s), a month of history is already ~26M engrams: one operator/admin-tool CALL after weeks of otherwise-fine operation is a multi-GB response, not something triggered by load. Prefer CALL patinadb.engrams(limit) (newest first, at most limit rows, O(limit) walk + memory) for any script or tool that does not genuinely need the whole history in one call. Page further back with CALL patinadb.engrams(limit, before), where before is the id of the LAST (oldest) row a previous call returned — chained calls tile with no overlap and no gap. The bare no-args call stays available (and stays byte-identical to before this issue) for tooling that genuinely wants everything at once.

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 now errors, it does not read as the empty graph (changed in issue #590). AS OF an id that is not in the database’s history (never existed, or squashed away by retention) fails with an actionable unknown engram … error, so a backend that stores engram ids and reads them back later can distinguish “that point in history is gone” from “nothing matched”. Even so, 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.
  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 persistence is asynchronous and node-local, not Raft-replicated. Committed entries survive restart, but a hard crash can lose the uncommitted tail; queue saturation or storage failures can prevent persistence. Ship patinadb::audit to a central sink and verify that pipeline’s delivery guarantees. See Security audit log.
  • 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.

First Customer: EU Writers, Global Readers, One Bolt Backend

For revision 0.15.0, the earlier two-learner 1M run failed its original relative latency gate (#1261). The user subsequently selected p90 <= 300 ms; relative 3x increases now warn in explicit backup/fan-out profiles. Old reports stay.

This is the scoped acceptance checklist for the deployment reviewed under issue #1261: three voting nodes in Europe, nonvoting learners near read traffic, and one trusted backend type that owns application RBAC. Application traffic uses Bolt; private management HTTP/CLI remains available to operators. This is not a production certification.

Repository scripts/search-blackbox/ adds an independently authored Rust/neo4rs search backend and source-derived HTTP oracle (#1261). The local two-hour test on 334,770 Jira summaries passes search/index correctness, bounded write-token recovery, ANN recall and offered-load admission across backend/DB restarts. Fault-time unavailability remains explicit:40 of14,400 read operations did not complete successfully, but no wrong answers or unresolved writes remained. This one-DB test does not qualify regional routing, concurrent multiwriter load or larger corpora. Method and full evidence: repository docs/search-blackbox-1261-2026-09-17.md. The Rust sample uses optional X-Write-Token with payload/principal-scoped native deduplication and a bounded replay window, not an indefinite request ledger. Its pinned neo4rs driver ignores URI routing-context parameters; the sample rejects those parameters rather than silently selecting another region. Configure an explicit regional bolt+s:// reader address separately from the routed neo4j+s:// writer connection. This is static reader selection, not automatic regional discovery or proven failover. Private CAs use NEO4J_CA_FILE. Read its API-SPEC and the token contract.

Driver qualification boundary: an earlier #1261 run exposed invalid write acknowledgements after a timeout. A separate scripted control reproduces pinned neo4rs’s own receive timeout returning a connection with outstanding protocol replies to its pool. The owned-task protection below covers caller cancellation, not this internal timeout. The sample now pins a local transport-safety patch that discards connections with unfinished replies or failed I/O instead of reusing them. Negative controls retain the unmodified published driver. The subsequent six-hour five-node/three-backend run passes216000reads and2160writes, both disjoint/shared phases, all eight controlled faults and final checks (#1261, docs/search-cluster-soak-1261-2026-09-17.md). This is local qualification, not independent-host production approval; results do not establish compatibility with unpatched neo4rs. The HTTP boundary now rejects empty/malformed write bookmarks as unknown outcomes rather than returning a successful ACK; no causal bookmark is invented.

The sample also renews its driver Graph after routing-discovery failures, for the next operation only. Pinned neo4rs can otherwise retain a writerless routing table after leader loss (#1261, cluster ledger and tests/routing_recovery.rs). This is an application-side mitigation, not an upstream driver fix: renewal requires the configured seed to be reachable. It does not retry the failed mutation, change its token or bookmarks, or turn an unknown write into an abort. Concurrent operations already using the old Graph finish independently; their late failures cannot discard a newer Graph. Live failover qualification remains required before relying on this sample for production.

The vendored driver additionally invalidates a cached database routing table after an explicit NotALeader or ForbiddenOnReadOnlyDatabase rejection in auto-commit run/execute, before its existing retry. This fixes retries targeting the former writer until the routing TTL expires. Discovered router entries are retained. It does not increase deadlines, add retries for ambiguous transport failures or replay explicit transactions; the writerless-Graph mitigation above remains separate. This local patch is not an upstream release.

The corresponding six-hour SSD fault run passes 216,000 reads / 2,160 writes, all eight faults and final oracles, with no failures outside injected-fault intervals plus the 60-second recovery window (#1261/#1304, docs/ssd-fault-soak-next-1261.md). The baseline had 21 late failed attempts; successful eventual token reconciliation alone had hidden that recovery defect. Raw failures during faults remain in the report. This qualifies the pinned sample/driver combination on one host, not unpatched neo4rs or a real WAN.

The follow-up cluster test reproduces a cancellation hazard in pinned neo4rs0.9.0-rc.10: cancelling an in-flight RUN can leave protocol replies that corrupt the next pooled request (#1261, repository cluster-soak report and tests/cancelled_driver.rs). The sample now bounds response waits without cancelling driver operations: at most16 owned tasks drain them before returning connections. Timed-out writes remain unknown outcomes, not rollbacks. Stalled tasks retain admission slots, so this trades saturation for safe bounded behavior; it is an application mitigation, not an upstream driver fix or production gate.

A documentation-driven sample backend and independent HTTP-only consistency simulator now live in repository scripts/customer-blackbox/ (#1261). They test backend-owned project/role authorization, opaque causal bookmarks across two backend processes, pagination/counts and recovery with three voters/two learners. Two local seeds pass; a deliberate value divergence is correctly rejected by the oracle. This is a small generated application fixture, not the customer’s actual backend or a1M-scale qualification. See repository docs/customer-blackbox-1261-2026-09-16.md for failed attempts, source provenance, limits and reproducible commands. The sample HTTP service is not a hardened public web server; keep it on loopback unless separately hardened.

The separate one-million-ticket extension passes its local steady-state gate with three voters, one shaped learner and two backends: 2,400 paced reads, 90 updates and 90 cross-backend causal reads, no dropped samples or wrong answers (#1261, docs/customer-blackbox-million-1261-2026-09-16.md, final run9). Mixed query p90 ranges from 3.757 to 116.561 ms; updates 8.784 ms and causal reads 18.918 ms. The sample uses separate global-write/regional-read drivers with redundant seed resolution. Restart/partition recovery retains acknowledged state, but leader loss still exposes unknown write outcomes and minutes-long fresh-reader catch-up. Unbounded first-page checks remain above the 300 ms paced gate. This is not full production approval or a substitute for idempotent write reconciliation and real WAN tests.

The RFC0032 archive/admin path passes the local 1M-ticket post-squash mixed-load backup and fresh-cluster HEAD/PITR restore gate (#1261, 2026-09-14; engineering ledger docs/archive-qualification-1261-2026-09-14.md). Tagged history, ordinary Bolt reads and non-publication controls pass. This is not off-host DR, actual disk exhaustion or physical learner throughput qualification. The earlier JSON PITR run exceeded its configured byte cap and did not reach restore; its failed report remains unchanged. See offline archive administration for its operational contract.

Latest-first learner qualification uses a separate end-to-end budget: checkpoint construction through the first successful causal Bolt read, not transfer alone. Historical completeness and historical content are checked separately; a node answering current reads does not imply all earlier Engrams are locally available. The renewed local gate passes causal/current reads before history completion and subsequent pre-cut historical-content checks. Its measured full-interval rate is 50.056 MB/s, so the strict >100 MB/s speed gate remains failed (#1261, 2026-09-15; engineering ledger docs/latest-onboarding-1261-2026-09-15.md). Follow-up transport batching reduces per-fragment file I/O scheduling, without removing verification or durable installation. The post-merge repeat does not demonstrate an end-to-end speedup; component benchmarks do not override this failed onboarding gate. Causal reads succeed after 84.693 s and history completion is observed after 114.257 s in that local 1M run, not an actual WAN deployment.

  • Put the three voters in independent European failure domains. Measure actual inter-voter latency; “Europe” and a timezone are not latency budgets. Losing one voter preserves a majority; losing two stops writes.
  • Keep remote replicas as learners. They are full-data replicas, not shards, and cannot automatically take over if the European majority is lost.
  • Use an EU routing driver for writes and explicitly selected regional direct drivers for reads. Use verified TLS (neo4j+s:// / bolt+s://). A direct driver URI selects one endpoint, not a regional failover list; the backend must manage that policy. Alternatively use the opt-in regional routing from RFC-0027 with scoped bookmarks; see Multi-Region Topology.
  • Pass causal bookmarks between write/read pools and backend instances whenever a request must observe an earlier write. A bookmark timeout is a real failure, not permission to retry without the bookmark. Unbookmarked replica reads may be stale; /ready is not a bound on WAN delivery lag.
  • Configure max_leader_contact_age_secs on regional learners using the application’s chosen admission budget. This opt-in guard also checks the last advertised leader commit against local apply progress and readiness_max_lag, and rejects stale admission through routing, readiness, local HTTP and Bolt. It is disabled by default; it is not a quorum lease or a guarantee about the age of every result in a long-running transaction.
  • Keep database ports private. End users never receive DB credentials or send arbitrary Cypher. Give the backend a non-admin, database-scoped service account; keep migrations, backups, peer secrets and root credentials separate.
  • Install entitlements allowing three voters and the intended data/history size on every node. Community defaults do not authorize this voter topology.

RBAC is an application responsibility, not disabled authentication

Backend-owned authorization fits this first customer if the backend is the only trusted data client. It does not require implementing every pending database property-level privilege in #1231 first. Database authentication, least-privilege service accounts, parameterized queries and network isolation still matter.

Define where authorization state lives. If permission checks read ACLs or revocations from this graph, an arbitrary regional read is not automatically fresh enough. Causal bookmarks order reads relative to known writes; they are not an unconditional linearizable read API. Bolt does not expose the REST linearizable-read option. Either keep authoritative authorization in the backend’s own consistent system or explicitly resolve this contract before launch. Do not silently treat a stale allow decision as current.

What needs proving before launch

The initial customer proxy workload is the repository’s Jira benchmark, starting at one million tickets before six million and the eventual sixty-million target. Local load targets are two reads/second and one ticket update every ten seconds, with a separate configurable nightly-upsert workload. The backend owns ACLs internally and connects using a scoped technical user; its source and actual driver are not available for this test. Explicit backup/fan-out profiles require per-query p90 <= 300 ms and warn above three times quiet-system p90 (user decision, 2026-09-08, #1261). Errors and missing coverage still fail; older measurements and Q6/Q8 exceptions are historical, not silently regraded. One-host measurements are not independent-region or off-host evidence.

GateRequired evidence
Onboarding#1251: checkpoint build, CurrentStateReady, and HistoryReady timed separately; source writes remain available. Prepared transfer throughput is not cold-onboarding throughput.
Customer workloadActual driver/version, query mix, indexes, data volume, concurrency and WAN conditions; throughput and latency distributions under normal operation and onboarding.
FailuresLeader loss, interrupted transfer, replica reboot, region isolation and recovery; acknowledged writes retained and causal reads correct or explicitly failed.
Retry contractBackend-specific ambiguous commits tested. Token use is approved; actual backend integration and capacity/retention limits still require qualification, not unconditional exactly-once claims.
Disaster recoveryOff-host backup restored into a fresh cluster, including graph/indexes and required history. Measured RPO/RTO and verified restore procedure.
OperationsDisk headroom, peer delivery lag, apply backlog, history completeness, failed backups, time synchronization and entitlements monitored.

The large onboarding harness uses real HTTP snapshot transport and Raft write probes. It does not replace the customer’s Bolt workload or a multi-continent network test. The 1M local measurements and their acceptance boundaries are in docs/jira-rollout-execution-2026-09-06.md in the source tree.

Priorities, not a larger prerequisite backlog

0.17.0 candidate: remaining launch gates (2026-09-10)

The fulltext optimization is measured on 334,770 real Jira summaries, not a combined graph/vector/fulltext workload at the eventual customer scale. The 0.17.0 results do not replace the release gates in #1261. Prioritize the following evidence rather than expanding language features:

  1. Recoverability first. The post-0.17.0 local follow-up now passes retained 1M-ticket HEAD and PITR restores, historical probes and Q1–Q10 reads (#1261). It fixes per-entry durable writes during compound-index construction; the earlier timeout remains a failed baseline. This follow-up is not yet a merged release. Next prove restoration into a fresh deployment from an independent off-host copy, with history, indexes and service-account checks, and agree on measured RPO/RTO. See source-tree docs/restore-1261-2026-09-10.md (#1261).
  2. Combined workload and recovery. Run the final candidate’s automated release gate, then the representative 1M Jira workload with the indexes that the backend actually uses, nightly upserts and concurrent learner onboarding. Progress to 6M only after passing, with RAM/disk measurements. Repeat voter loss, learner restart and partition recovery while preserving causal reads. Separate graph readiness, index readiness and complete historical readiness.
  3. Cold onboarding. The measured large prepared-checkpoint transfer is not proof of the requested >=100 MB/s cold path. Checkpoint creation included, the recorded result remains below that target (#1251). Re-measure the whole path under workload; initial indexing improvements alone do not establish a faster physical checkpoint copy. See Learner onboarding. A fresh logged 1M-ticket run on 2026-09-10 passes graph/catch-up checks: build 31.268 s, join/current HTTP read 11.697 s, then 25.678 s for history completeness (#1261). Build-plus-join is 161.783 MB/s allocated-source but 98.659 MB/s snapshot bytes; this does not pass a strict 100 MB/s snapshot-byte gate. OS caches were uncontrolled; causal Bolt and query/index readiness remain unqualified by this instrument. See the repository ledger docs/cold-onboarding-1261-2026-09-10.md. The subsequent local 1M combined run, with a not-yet-released snapshot staging fix (#1287), passes 1,200 mixed reads, 60 updates and 30 batches without errors or drops; highest source/learner query p90 is 122.618/110.617 ms (#1261). Its continuous trigger-to-causal-Bolt-read timer is 79.343 s (53.431 MB/s snapshot bytes), so the strict onboarding speed target remains unmet. Learner SIGKILL recovery passes via retained logs and via snapshot plus tail; release/partition controls pass separately at small fixture sizes. See docs/combined-rollout-1261-2026-09-10.md. 6M is explicitly deferred. Administrative CREATE TAG caused foreground write stalls in an earlier run; the passing mixed workload does not qualify tag creation under load.
  4. Actual backend and network. Qualify the real driver and query templates, cross-pool bookmark propagation, regional selection/fallback, ambiguous commit retries and token retention/capacity. Run verified TLS and actual WAN tests; a one-host TCP impairment test is narrower. Backend-owned RBAC avoids requiring per-property database privileges, but still needs private endpoints, scoped credentials and an explicit freshness contract for authorization.
  5. Operational acceptance. Verify alerts and runbooks for failed backups, free disk, replication/apply lag, incomplete history, clock skew and expiring certificates/entitlements. Three European voters need independent failure domains; worldwide learners do not provide an automatic write quorum after loss of the European majority.

Before releasing a candidate, run python3 scripts/release-gate.py from its source checkout with an explicit new output directory and the driver Python environment. The source-tree docs/release-gate.md gives the full command and prerequisites. It bundles token/retention/capacity, ambiguous-retry soak, crash, causal recovery, local restore and official-driver checks. Missing tests or missing actual dedup hits fail rather than producing a misleading green verdict. The user selected idempotency tokens for the first customer on 2026-09-08; capacity/retention and clock-monitoring requirements remain mandatory. The JSON result is local engineering evidence, not customer-backend, real-WAN or off-host qualification, and never publishes a release automatically.

Fix correctness/security regressions before throughput tuning. Track durable whole-install crash recovery in #1263, current cache availability in #1258, conservative startup-format selection in #1257, public version metadata in #1260, native Bolt ROUTE semantics in #1262, and delivery-lag freshness in #1133. The integration sweep and its current validation are recorded in docs/p1-p2-sweep-2026-09-06.md; merged fixes do not close those release obligations.

The follow-through audit also fixes query normalization (#1264) and Bolt authentication transitions (#1265). Checkpoint publication now separates immutable .json artifacts from mutable .recv transfer files; an old-version partial transfer can restart from zero during upgrade. See docs/bug-audit-2026-09-06.md in the source tree for validation boundaries.

PRs #1266 and #1267 are merged. The current local rollout wave adds six bounded acceptance cases: the three-voter/five-learner entitlement boundary, learner outages plus leader loss, replicated RBAC role downgrade through snapshot and failover, and a portable PITR restore after source-data loss with graph, index, user-role and historical-read checks, unconfirmed-learner removal recovery, and eviction refusal with a health-up but Raft-partitioned voter. These are local regression cases, not an aggregate gate result or customer qualification. The external workload, WAN, off-host restore, operations and retry-release evidence in the table above remain required before launch.

The first local 1M-ticket measurement is not a release approval. Physical current-state onboarding and current-state recovery pass their local checks, but two deep traversal-page query families exceed the requested relative latency budget under updates; the user accepted those measured Q6/Q8 exceptions for this workload. A separate pre-cut tag check exposed an improperly finalized raw benchmark import, not qualified production history. Unlogged imports need an explicit history base; delta-only asynchronous backfill does not transport snapshot-derived bases. Do not treat current readiness or the history status flag alone as proof that historical answers are complete. See docs/jira-rollout-execution-2026-09-06.md in the source tree.

The fully logged 1M-ticket local worker drill additionally passes real SIGKILL and same-directory restart while voters acknowledge creates, updates and deletes: both retained-log catch-up and a purged physical snapshot followed by newer log entries. See docs/jira-worker-restart-2026-09-07.md for exact evidence and scope. A restarting worker may refuse a causal read with Neo.TransientError.Transaction.BookmarkTimeout while behind. Snapshot directory replacement may invalidate an existing session with Neo.ClientError.Security.Unauthorized and authorization changed; authenticate again. The backend needs bounded read retries preserving the bookmark and, for that specific authorization-generation change, a fresh authenticated connection. Do not retry arbitrary authorization failures or replay acknowledged writes.

Region-aware server routing, native Bolt client-certificate authentication, property-level DB RBAC and epoch/cold-history storage (#1158) are optional for this private-backend contract unless explicitly required. They should not obscure the missing workload, failure and restore evidence.

See Production Deployment, Multi-Region Topology, and Disaster Recovery for operating procedures.

Disaster Recovery

This is the operator playbook for backing up patinaDB and recovering from the failure modes a production deployment actually hits: one node down, the leader down, the whole cluster/host gone, and “someone ran a bad write.” It is deliberately concrete — exact endpoints, exact commands, exact caveats — not a restatement of the architecture chapters.

Qualification status (#1261): the later local 1M-ticket HEAD/PITR restore drills pass after the generic compound-index build fix; see docs/combined-rollout-1261-2026-09-10.md and the first-customer chapter. Those measurements do not qualify the new asynchronous backup-job path at 1M. The older timeout evidence remains in docs/backup-nightly-2026-09-08.md.

For asynchronous server backups, use the maintenance-job API. It produces the same portable HEAD/PITR format described here, but separates submission, preparation and download. ready is a local artifact, not an off-host recovery copy. The synchronous /mgmt/snapshot endpoint remains available.

Scope: this covers the clustered server. Everything here also applies, minus the Raft-specific sections, to a standalone embedded database directory via the CLI backup/restore commands.


1. What each backup mechanism gives you

There are three distinct mechanisms. They are not interchangeable — know which one covers which failure.

MechanismWhat it capturesWhere it livesUse for
Raft snapshot (automatic, internal)A point-in-time copy of the graph(s) + schema defs, taken by the leader for log compaction / learner bootstrap._raft_snapshots/ inside each node’s own data dir.Learner/follower catch-up. Not a disaster-recovery artifact by itself — it lives on the same disks the cluster runs on.
GET /mgmt/snapshot (portable backup, HEAD-only)Every database’s current (HEAD) graph + compound indexes, UNIQUE/existence/node-key/property-type constraints, fulltext/vector/point/edge-sorted index defs, WASM function defs (module bytes included), tag names (re-pointed at the restored HEAD), and RBAC users. Streamed to a file you download and store off-box.Wherever you save the downloaded file (S3, another host, tape — your choice).Rebuilding a cluster from scratch after total loss, when you only need the current state (not historical AS OF).
GET /mgmt/snapshot?history=true (portable PITR backup)Everything the HEAD-only backup carries, plus the full engram delta/snapshot timeline for every database.Same — a downloaded file.Total loss and you need AS OF <old-engram> / AS OF TAG / patinadb.diff(...) to work after the rebuild (point-in-time recovery, not just “current state”). Requires a Pro/Enterprise license — Community nodes get 403 Forbidden on ?history=true (plain HEAD-only backup is unaffected, every edition). See Editions & Limits.
CLI patinadb <db> backup <file> [--history]The embedded/offline equivalent of the two REST forms above, for a directly-opened database directory (no server, no Raft) — same file format, same HEAD-vs---history distinction. Not license-gated (the CLI has no license/entitlement layer).The file you point it at.Embedded deployments, or backing up one database file directly from a node’s disk while the process is stopped.

The shared whole-history importer stops the process if a storage error occurs after replacement writes have begun (#1443); it must not return control with a partially replaced timeline. Reopening recovers the prior durable history. An enclosing deferred transaction is rejected before import begins. This does not make the preceding chunked graph restore atomic. Clustered history restore has the separate protocol and publication requirements below.

What none of these carry

Be honest about the gaps before you rely on any of them:

  • PITR restore needs all members on peer protocol 21. The leader sends the history in blocks of at most 1 MiB and each replica verifies its complete length and SHA-256 before publishing the history and release markers together. An older or unconfirmed cluster receives 409 before graph writes; there is no leader-local fallback. Apply can lag on followers: wait for their applied frontier before verifying history there. New learners obtain pending restore state through snapshots and older timeline through normal history backfill; wait for history readiness before directing time-travel queries to them. Temporary disk usage includes the durable encoded blocks and private verified input. Decode memory remains proportional to the history; the 1 MiB block size is a transport bound, not a total-memory limit. The existing restore upload cap still applies. Graph restore remains chunked and its documented rollback is graph-only; this is not an atomic transaction over the entire restore endpoint.
  • Tags lose their original engram id in a HEAD-only backup. A tag name survives restore and still resolves — but it now points at the restored HEAD, not the historical engram it originally named, because a HEAD-only backup carries no history to re-point it at. Only a ?history=true restore preserves what a tag actually pointed to.
  • Restore is additive, not a wipe. /mgmt/restore (and the CLI without --force) is meant to populate a fresh/empty registry. It creates each database (idempotent) and replays graph ops on top of whatever is already there — restoring into a cluster that already has data in the same database will not give you a clean replacement, it will merge/duplicate. Always restore onto a freshly bootstrapped, empty cluster. The REST endpoint’s ?force=true flag does not change this: it merely lifts the non-empty-target refusal so the restore is allowed to merge — it never deletes existing data. And for a PITR backup the guard is absolute: installing the engram history onto a database that has data or a timeline of its own is refused, with or without ?force=true.
  • A restore starts a new database identity. Once every member runs protocol 17, /mgmt/restore finishes by giving each restored database a new incarnation (also when the name already existed and ?force=true merged into it), resets its history generation to 0 and records where the content came from (restored_from: the source incarnation and history generation at backup time). Backups taken by older servers carry no source identity; they still restore, with no recorded lineage. Below protocol 17 the restore keeps the target’s existing identity, as before. A failed restore is rolled back before this step, so it never changes the identity.
  • The Raft snapshot is not off-site. It lives in each node’s own data directory. If you lose the disks (or the whole host/rack/region), the Raft snapshot is gone with everything else. Only the downloaded portable backup file is a real disaster-recovery artifact — store it somewhere the cluster’s own failure can’t take with it.
  • Community-mode 30-day retention limits how far back a live AS OF can reach, independent of backups entirely: engrams older than the history-retention window (30 days on Community, unlimited on Pro/Enterprise — see Editions & Limits) are periodically compacted (squashed) on the running cluster. This governs recovery from a bad write via live time-travel (§4d), not the portable backup’s own timeline — a ?history=true backup taken before a squash boundary still carries what existed at backup time.
  • A backup is a snapshot, not a continuous log. Between two backups, anything written and then lost is gone unless it’s still inside the live cluster’s own retention window (§4d covers that path). Backups are for “the cluster itself is gone,” not a substitute for the built-in time-travel retention.
  • Backups taken by older releases may not carry every definition family. The backup format has grown over time (constraints, index families, WASM function defs were added field by field). Every field is optional, so an older backup still restores with a current binary — but the CLI restore prints a loud warning naming the def families the old file cannot have carried. Re-create those definitions manually, or re-take the backup with a current binary.

There’s no one right cadence — it’s a trade-off between RPO (§3) and storage/operational cost. As a starting point for a production deployment:

TierCadenceWhich endpointRetain
Community (no PITR)Daily GET /mgmt/snapshot (HEAD-only)Only option available7 daily + 4 weekly
Pro/Enterprise, moderate write rateDaily GET /mgmt/snapshot?history=truePITR7 daily + 4 weekly + 12 monthly
Pro/Enterprise, high write rate / low RPO toleranceEvery 1–6h ?history=true, daily kept longerPITRHourly for 48h, daily for 30d, monthly beyond

Practical notes:

  • Since 0.15.1, portable restore decodes the request incrementally without retaining a separate full raw-JSON buffer. The default upload limit remains 1 GiB. Set PATINADB_RESTORE_MAX_BODY_BYTES on the restore target to a positive byte cap when an artifact is larger (0/invalid values retain the default). The qualification profile explicitly selects 16 GiB, not a universal production recommendation. Decoded graph/history objects still occupy RAM; size the target and cap accordingly. Only one portable restore per node is admitted; concurrent requests receive retryable HTTP 503. Uploads have a 30-second between-chunk idle deadline and a 30-minute decode/upload deadline. Existing pristine-target and non-atomic chunked-apply rules still apply.

  • The root operator password is target-local startup configuration and is not replaced by portable user restoration. Reconnect application users using their restored credentials, but keep using the target’s operator credentials for management. A completed download alone is not a verified restore.

  • ?history=true costs more (it streams the whole engram delta/snapshot timeline, not just current HEAD) — measure the file size and export time against your actual write volume before committing to an hourly cadence.

  • The export takes a leader linearizability barrier first (/mgmt/snapshot is leader-only — a follower returns a 503 leader hint) and is a consistent cut: every database’s HEAD label, its graph, and its engram history are read from one pinned point in time, so you do not need to quiesce writes while a backup streams. Writes are briefly serialized only while that point is pinned, never for the duration of the stream — measured under issue #1105, a 212 MB PITR backup taking 1.73 s moved a concurrent writer’s median latency from 3116 µs to 3333 µs (+7%), with no stall resembling the backup’s own duration. See Backup & disaster recovery for the full measurement and the caveat for older builds.

  • Store backups off the cluster’s own infrastructure (different host, different failure domain, ideally a different region/AZ) — a backup that lives on the same disks/rack as the cluster is not a disaster-recovery artifact, it’s a second copy of the same single point of failure.

  • Verify every backup after taking it (§5) — an untested backup is a guess, not a plan.

  • Alert on backup age. A node stamps the Unix time of every successfully completed /mgmt/snapshot stream (a fully delivered download; an aborted download never stamps) and exports it as the Prometheus gauge patinadb_backup_last_success_timestamp_seconds — persisted across restarts, and ABSENT (no series, not a 0) on a node that never completed a backup. The shipped alert rules PatinaDBBackupStale (cluster-wide newest backup older than your RPO + buffer — default 26 h, tune to your cadence) and PatinaDBBackupNeverRecorded (no backup ever completed for 25 h — catches “the backup cron never existed”) turn “the backup cron silently stopped” from a restore-day surprise into a page.


3. RPO / RTO

RPO (Recovery Point Objective) — how much data you can afford to lose, measured as time since the last durable capture point. RTO (Recovery Time Objective) — how long recovery is allowed to take.

3a. Single node / follower lost

ValueDriven by
RPOZeroThe cluster keeps committing on the remaining voters; no committed write is lost.
RTOSeconds to minutesTime to restart the node or replace it and let it rejoin as a learner. Catch-up time is the streamed-snapshot install (memory-bounded, proportional to graph size) plus replaying the log tail since the snapshot — bigger graph ⇒ longer catch-up, but bounded and predictable.

3b. Leader lost (quorum intact)

ValueDriven by
RPOZeroRaft never acknowledges a write to the client until it’s durably replicated to a quorum; a leader crash cannot lose an acked write.
RTOSub-second to a few secondsAutomatic — the election timeout (750–1500 ms) plus one election round. No operator action required in the common case (see §4b for what is manual).

3c. Whole cluster / host / region lost — restore from portable backup

ValueDriven by
RPOTime since the last backup you took (§2) — everything committed after that backup and not otherwise recoverable is lost. With ?history=true PITR the RPO can be pulled forward within the backup’s own captured timeline (restore, then AS OF to the exact last-known-good engram) but nothing committed after the backup was taken is recoverable at all.Backup cadence. This is the dominant lever — tighten it by taking backups more often.
RTOMinutes to hours, dominated by: (1) provisioning new host(s), (2) downloading the backup file, (3) /mgmt/restore replay time (chunked at 5,000 ops per Raft entry — proportional to graph size, not O(1)), (4) re-adding voters/learners and letting them catch up (§4a), (5) DNS/client repointing. Budget the restore replay time against your graph size in a drill (§5) — don’t guess.Graph size, backup file transfer time, number of nodes to rebuild.

3c-bis. Majority permanently lost, a minority survives — the case that surprises people

This is the gap between 3a and 3c, and it has the worst RPO of the three, which is counter-intuitive because a node with your data is still running.

Concretely: 2 of 3 voters are permanently destroyed (host loss, disk loss, a region gone) and one node is still up, holding committed writes made since your last backup.

ValueDriven by
RPOTime since the last backup — the same as 3c, even though a live node still physically holds newer data.Backup cadence.
RTOAs 3c (restore from backup onto new hosts).As 3c.

Why the surviving node’s newer data is not recoverable. Raft requires a quorum for every membership change, so the survivor cannot shrink the cluster to itself: POST /mgmt/evict-voter deliberately refuses with WouldBreakQuorum rather than hand you a configuration that cannot commit. That refusal is correct — it is the same guard that stops you accidentally stranding a healthy cluster.

patinaDB deliberately ships no “force new cluster” escape hatch. Some systems offer one (take the survivor’s data directory, force it into a single-member cluster). It is a genuine footgun: if the other nodes are not actually dead — a network partition that looks like host loss, a host that comes back — you have created two clusters that both believe they are authoritative, and the resulting split-brain loses acknowledged writes silently. We judged the blast radius of the recovery tool worse than the scenario it recovers.

What this means for you, practically: for the majority-loss scenario your backup cadence IS your RPO — the running survivor buys you nothing. If that RPO is unacceptable for your workload, the lever is more frequent backups (§2) and/or spreading voters across failure domains so that losing a majority at once is not plausible in the first place. Size the cadence against this row, not against 3a’s zero.

3d. Bad write / logical corruption

ValueDriven by
RPOZero for the “restore-forward” path (§4d) — if the bad write is caught within the live retention window (30 days Community, unlimited Pro/Enterprise), AS OF + CALL patinadb.restore(...) recovers to the exact engram immediately before the bad write, with no data loss beyond the bad write itself. Outside the retention window, falls back to §3c’s RPO (last backup).How quickly the bad write is detected, and the retention window.
RTOMinutes — this is a live-cluster operation (time-travel query + a promote-to-HEAD call), not a rebuild. No new hosts, no restore-file transfer.Time to identify the correct engram id to restore to.

3e. Physical (on-disk) corruption on one node — a different failure from 3d

§3d is a logical corruption: the database faithfully stored what it was told, and what it was told was wrong. This one is the opposite — the bytes on disk no longer decode. A bit flip, a failing drive, a filesystem that lost a page.

What you will see. patinaDB is deliberately fail-loud here. A record that is present-but-undecodable is never treated as absent and never silently skipped: it is classified as a storage fault and it halts that node, rather than letting it keep serving answers computed from data it could not read. That is the intended behaviour and it is what keeps a corrupt replica from silently diverging from its peers — but it does mean one bad record takes a whole node out, which is a larger blast radius than a sharded system would have for the same fault.

ValueDriven by
RPOZero in a cluster — the corrupt node is a replica; its peers hold the same committed state. Last backup for a single node (embedded, CLI, or a one-node server), which has no peer to recover from.Whether the deployment is replicated.
RTOMinutes in a cluster (§4f: wipe and re-sync from the leader, zero downtime for clients). §3c’s RTO for a single node (restore from backup).Graph size, for the re-sync/restore transfer.

The important consequence: in a replicated cluster this is a routine event with no data loss — the whole point of running three nodes. On a single node it is a restore-from-backup event, and your RPO is your backup cadence. That asymmetry is the strongest operational argument for replication, stronger than the failover argument, because a bit flip needs no operator error and no outage to happen.


4. Recovery procedures

All curl examples assume -u neo4j:"$PW" (or the appropriate RBAC user) unless the endpoint is explicitly auth-exempt.

4a. Single node lost — rejoin / learner catch-up

  1. Confirm the cluster is otherwise healthy: GET /mgmt/cluster on any surviving node — check current leader + voter/learner list.
  2. Bring the failed node back (same disk/data dir if recoverable, or a fresh empty data dir if the disk is gone):
    • Same disk recoverable: just restart the server with the same --id/--addr/--db. It resumes from its own persisted Raft log/state and catches up on the tail.
    • Disk lost / fresh replacement: start it fresh, then have an existing member add it as a learner:
      curl -u neo4j:"$PW" -X POST http://<leader>:21001/mgmt/add-learner \
        -H 'content-type: application/json' \
        -d '{"id": <new-id>, "addr": "<new-node-host:21001>"}'
      
      It bootstraps via the streamed Raft snapshot (memory-bounded, not proportional to total graph size in RAM) and then replays the log tail.
  3. Watch it catch up: GET /ready reports 503 {"reason":"installing"} while installing, then {"reason":"lagging"} while catching up the tail, then 200 (see Health vs. readiness probes).
  4. Once caught up, promote it to a voter if it isn’t already (only needed if it’s a fresh id, not a resumed one):
    curl -u neo4j:"$PW" -X POST http://<leader>:21001/mgmt/change-membership \
      -H 'content-type: application/json' -d '[1,2,3]'
    

No backup is needed for this path — it’s normal Raft self-healing.

This same fresh-empty-data-dir + rejoin procedure is also the zero-downtime way to reclaim disk from a .redb main file that’s grown far larger than the live graph — see Reclaiming disk from a bloated .redb file, which covers both this rolling-rebuild option and the offline patinadb <db> compact CLI command for a node you can afford to take fully down.

4b. Leader lost — auto-failover

Automatic: the remaining voters elect a new leader within one election round (sub-second to a few seconds); writes acknowledged before the crash are never lost (§3b). Clients following the 503 leader-hint (or the Bolt Neo.ClientError.Cluster.NotALeader forward) or using neo4j:// routing re-route with no operator action.

Manual, only if the old leader doesn’t come back:

  1. Confirm the new leader: GET /mgmt/cluster on any node.
  2. If the dead node was a voter you don’t intend to bring back, evict it so it stops counting toward quorum math (see §4e) — otherwise a second failure can cost you quorum.
  3. If it will come back, just let it rejoin as a follower (§4a) — no membership change needed, it resumes as the id it already was.

4c. Whole cluster lost — bare-metal restore from a portable backup

  1. Provision fresh host(s) for the new cluster. Start node 1 with --bootstrap on an empty data directory (this is important — restore is additive, not a wipe; see §1). For a ?history=true (PITR) backup a pristine target is not just important but required: installing the history onto a database that has data or a timeline of its own is refused outright.
  2. Get the backup file onto the new node (however you stored it — S3, scp, etc.).
  3. Restore it:
    curl -u neo4j:"$PW" -X POST http://<new-leader>:21001/mgmt/restore \
      -H 'content-type: application/json' \
      --data-binary @patinadb-backup.json
    
    No ?force=true — the target is empty, so the non-empty-target guard does not fire, and force would only ever permit a merge, never a wipe. (For an embedded/CLI-managed database instead of a server cluster: patinadb ./fresh.db restore ./backup.bak.)
  4. For a PITR backup, restore once through the leader after every cluster member confirms peer protocol 21. Wait for each member to apply the restore, then check AS OF, original tag targets and patinadb.diff on each node.
  5. Add remaining voters as learners and promote them (§4a step 2/4). They receive graph state through Raft snapshots and history through backfill. Wait for history readiness as well as graph catch-up; they do not need a separate portable restore or a private copy of the backup file.
  6. Verify before declaring done (§5) — node/edge counts, a known query, and (if PITR) an AS OF probe.
  7. Repoint clients/DNS/neo4j:// routing at the new cluster.

4d. Bad write / logical corruption — restore to a point-in-time

Prefer this over a full rebuild whenever the mistake is caught within the live retention window — it’s faster (§3d) and needs no backup file at all.

  1. Find the last-known-good engram, e.g.:
    CALL patinadb.engrams() YIELD id, parent, timestamp, message
    RETURN id, timestamp, message ORDER BY timestamp DESC LIMIT 20
    
    or diff a suspect engram to see exactly what it changed:
    CALL patinadb.diff('<engram-id>')
    
  2. Confirm the state at that point looks right with a read-only time-travel query (no risk — time travel never writes):
    curl -u neo4j:"$PW" -X POST http://<leader>:21001/cypher -H 'content-type: application/json' \
      -d '{"query":"MATCH (n) RETURN count(n)","at":"<good-engram-id>"}'
    
  3. Promote that historical state back to HEAD as a new, append-only engram (nothing is rewritten or deleted from history):
    CALL patinadb.restore('<good-engram-id>')
    
    This replicates through Raft like any other write (see Engrams → Restore).
  4. If the bad write is older than the live retention window (30 days Community / unlimited Pro/Enterprise — see Editions & Limits), it has already been compacted out of the live cluster’s history and this path is unavailable; fall back to §4c using the most recent ?history=true backup that predates it.

4e. Permanently-dead voter

A dead voter that isn’t coming back should be evicted — otherwise it continues to count toward quorum and one more failure can cost you write availability.

curl -u neo4j:"$PW" -X POST http://<leader>:21001/mgmt/evict-voter \
     -H 'content-type: application/json' -d '{"id": <dead-node-id>}'
  • Must be run on the leader (a follower returns a 503 leader hint).
  • Live-quorum guard: refused with 409 if the resulting voter set couldn’t still form a majority — it will not hand you a cluster that can’t commit. Evicting an unknown id or the last remaining voter is 400.
  • Check the result with GET /mgmt/cluster. Then add a replacement via /mgmt/add-learner + /mgmt/change-membership (§4a) to get back to full voter count. See also Removing a dead voter.

4f. Physical corruption on one node — wipe and re-sync from its peers

For the failure described in §3e: a node halts with a storage/decode error (the log will name the fault; it is not a query error and not a crash loop you should try to ride out).

Do not try to repair the file. There is no record-level repair tool, and deliberately so: the node’s peers already hold the same committed state, which makes a rebuild both faster and more trustworthy than any surgery on a file whose integrity is already in question.

In a cluster (no data loss, no client downtime):

  1. Confirm the rest of the cluster is healthy first — GET /mgmt/cluster on a surviving node, and check a write still commits. If the corrupt node was a voter and you are now at bare quorum, do step 2 one node at a time.
  2. Stop the corrupt node if it has not already halted itself.
  3. Move its data directory aside — do not delete it yet. Keep it until the rebuild is verified; it is the only evidence if this turns out to be a failing drive rather than a one-off flip.
  4. Start it on a fresh, empty directory, same --id and --addr. It will catch up from the leader, by log replay if it is recent enough and by a streamed snapshot otherwise. No membership change is needed: the node id is unchanged, so it is still the same cluster member.
  5. Verify with §5 — and specifically confirm the rebuilt node reports /ready as serviceable, since that is the check that will not pass until it has genuinely caught up.

This is the same “rolling rebuild onto a fresh directory” procedure documented under Reclaiming disk from a bloated .redb file; it is written up there for that use case, but the mechanism is identical and this is the other reason to reach for it.

Then look at the disk. One corrupted record is a symptom, not an incident. Check SMART/dmesg on that host before putting it back into rotation — if the drive is failing, the rebuild will corrupt again, and the second occurrence is much more expensive to diagnose than the first.

On a single node (embedded, CLI, or one-node server) there is no peer, so this is a restore-from-backup event — §4c, with §3c’s RPO. This asymmetry is the strongest operational argument for running three nodes.


5. Verifying a restore actually worked

Never consider a restore (or a backup) trustworthy until you’ve checked it. At minimum, after any restore in §4c or §4d:

  1. Counts match expectations. Compare node/edge counts against what you expect (from GET /version’s usage block, or a Cypher count query) — not necessarily bit-for-bit against the pre-disaster source (writes may have landed between backup and disaster), but in the right ballpark and never suspiciously zero/empty.
    MATCH (n) RETURN count(n)
    
  2. A known query returns the expected shape. Pick something business-meaningful and stable (a specific node by a unique key, a relationship count for a well-known entity) and check it by hand.
  3. Schema/constraints came back. SHOW CONSTRAINTS, SHOW INDEXES, SHOW EDGE SORTED INDEXES, SHOW FUNCTIONS — confirm the defs you expect are present (they should be, per §1’s table), not silently dropped. For a backup taken by an older release, expect the CLI restore warning naming the def families the old file could not carry (§1).
  4. If PITR (--history/?history=true): confirm AS OF actually reaches history, not just HEAD — pick a known older engram id (if you captured one before the disaster) and confirm it reconstructs the expected historical state, on the specific node you restored the timeline onto (§4c step 4):
    curl -u neo4j:"$PW" -X POST http://<restored-node>:21001/cypher \
      -H 'content-type: application/json' \
      -d '{"query":"MATCH (n) RETURN count(n)","at":"<known-old-engram-id>"}'
    
  5. Cluster health. GET /mgmt/cluster shows the expected voters, GET /ready is 200 on every node, and each node’s applied log index has converged on the leader’s.

Drill this periodically (quarterly is a reasonable default for most deployments; more often if write volume or business criticality is high) — restore a real backup into a scratch cluster, run the checks above, and time it against your RTO target (§3c). An untested restore procedure is not a disaster-recovery plan.

The shipped scripts/verify-restore.sh (see Backup & disaster recovery) automates steps 1–2 for a quick post-restore sanity check; it does not replace a full drill.


See also

  • Production Deployment — the 3-node bring-up and the §6 backup/restore quick reference.
  • REST API — the /mgmt/snapshot, /mgmt/restore, and /mgmt/export endpoint reference.
  • Editions & Limits — the PITR (?history=true) license gate and the history-retention window.
  • Engrams — CALL patinadb.restore(...), tags, AS OF.
  • Day-2 Operations — readiness semantics, the rolling-rebuild disk-reclaim procedure.
  • High Availability — failover behaviour, membership changes, snapshot transfer bounds.

Refused snapshot repair: re-seed required

A returning replica may have applied more log entries than an incoming snapshot contains while still missing the snapshot’s graph history. Repair needs the full log tail above that snapshot’s cut. If those entries have already been purged, patinaDB refuses the repair before replacing the graph or changing its persisted applied position. The node records a durable re-seed required fence: reads and writes stay fenced, further snapshot installs cannot clear it, and startup reports the recovery instructions.

Stop the affected node and preserve its data directory for diagnosis. Verify the healthy cluster and recovery source, then replace/rejoin the replica using a fresh data directory through the supported disaster-recovery procedure. Do not remove just the fence, reset last_applied, or downgrade to bypass the check: those actions do not restore the missing data.

When the required tail exists, a forced repair retains its own checked copy before graph replacement. Boot recovery can therefore finish after a later Raft log purge. It validates the retained copy before modifying the graph and publishes a checkpoint covering the replayed tail before removing the install intent. These recovery descriptors use local format version 2; version 1 is still read, with its original live log tail checked before restore. This does not change the Raft snapshot wire format or existing stored property encodings. An interrupted version-2 install must be recovered by a binary that understands that descriptor.

Configuration Reference

A consolidated reference for the server’s knobs.

Cypher CASE depth

PATINADB_MAX_CASE_DEPTH bounds the total number of unquoted CASE keywords in a statement, including independent sibling expressions. Its default is 200 in release builds and 56 in debug builds; positive overrides are clamped to 320 and 60 respectively. Zero or an invalid value selects the default. The effective cap cannot exceed PATINADB_MAX_EXPR_DEPTH (default 200). Exceeding the cap returns a syntax error before recursive parsing.

Debug builds give CASE-heavy parsing bounded extra stack on the caller’s thread (up to an 8 MiB temporary stack when needed). This preserves the existing limits and thread-local query scopes. On platforms where sufficient stack capacity cannot be established, these debug queries fail with an explicit capacity error. Release builds retain their existing parsing path; this is not a higher query-depth limit or a new configuration toggle.

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. Use a long, random value (16+ characters) — issue #1065 exempts this exact username from the failed-login rate limiter below (a single-service-principal deployment must never be able to lock itself out of its own account), so unlike a directory-user password, no per-minute guess cap backs this one up. A short value logs a loud startup warning but does not refuse to boot.
--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 the /metrics gate — serve it open on a private monitoring network. By default /metrics needs a credential and the global Admin role (issue #693: its db=<name> labels would otherwise let a per-db Reader enumerate other tenants).
--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 client statements, node-wide — every REST request AND every Bolt statement (read, write, or inside an explicit transaction). Excess is shed with 503 on REST and a driver-retryable Neo.TransientError on Bolt. 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.
--max-leader-contact-age-secs <seconds>unset (disabled)Additional follower/learner freshness gate: require recent leader contact and bound last advertised leader-commit versus local-applied lag using readiness-max-lag. Applies to local read eligibility and readiness, not peer freshness inference.
--otel-endpoint <url> (PATINADB_OTEL_ENDPOINT)unsetEnable OTLP/HTTP diagnostic span export. Request correlation remains available without export; JSON logs are configured independently. See Day-2 operations.
PATINADB_TRACE_SAMPLE_PERCENT1Integer 0–100: local trace-ID sampling percentage when OTLP is enabled. A caller’s sampled flag cannot override it.
PATINADB_TRACE_SPANS_PER_SECOND128Integer 1–1024: node-wide admitted sampled-span budget per one-second window, not a request-rate limit. Excess spans are dropped.
--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). It also bounds the initial leader/applied admission barrier for bulk upserts and the ordering/read-index wait for REST pipelined writes (/cypher autocommit and bulk), together with the remaining query timeout. Bolt pipelined writes are bounded only by the query timeout; lowering it can increase write admission refusals. 0 removes this admission bound but leaves the query timeout in force. See Bolt → Causal consistency and the write retry contract.

Environment: PATINADB_AUTH_PASSWORD sets the auth password (preferred over a shell flag).

Regional routing and scoped bookmarks

These optional YAML keys have matching kebab-case CLI flags and uppercase PATINADB_ environment variables. Existing flag/environment-over-file precedence applies. Defaults below are resource/rollout policy, not measured latency promises.

YAML keyDefaultMeaning
causal_bookmark_modelegacylegacy, compatible, or strict; see Bolt.
regional_routing_enabledfalseRequires compatible/strict mode and positive bookmark timeout.
node_nameunsetAdvisory display label, 1–128 UTF-8 bytes, no controls. Numeric node ID remains authoritative.
node_regionunset1–64 lowercase ASCII letters/digits/_/-, starting with a letter or digit.
regional_routing_table_ttl_secs30Table TTL cap, 1–300 seconds; actual TTL may be zero.
regional_routing_observation_max_age_secs15Fresh-profile lifetime bound, 1–300 seconds.
regional_routing_coordination_timeout_ms3000Total coordination deadline, 1–30000 milliseconds.
regional_routing_max_concurrent16Separate request/profile admission caps, 1–1024.

See Multi-Region Topology for routing URIs, reader tiers, failure behavior and the all-members-upgraded prerequisite.

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)falls back to --auth-passwordDedicated peer /raft/* secret, separate from the root-admin credential so the two rotate independently. Required for clustered nodes: with auth enabled, a non-bootstrap node refuses to start without a dedicated value (a single --bootstrap node may omit it, with a warning). All nodes of a cluster must share the same value.
--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.
--provenance-required (PATINADB_PROVENANCE_REQUIRED)offRefuse (409, before proposing) a write whose provenance/tx_metadata context is non-empty but Anamnesis is off for the target db, instead of silently dropping the context (issue #1071). REST autocommit only today — Bolt writes are not yet covered. See Anamnesis.
--auth-max-attempts-per-min <n> (PATINADB_AUTH_MAX_ATTEMPTS_PER_MIN)20Brute-force auth throttle, per source IP and per username, for directory (CREATE USER) accounts. Blocks a failed-login flood before it reaches the argon2 verify. Set 0 to disable (startup logs a warning). Does not apply to the root-admin username (--auth-user/--auth-password) — issue #1065 exempts it entirely; see --auth-password above.
--auth-verify-cache-ttl-secs <n> (PATINADB_AUTH_VERIFY_CACHE_TTL_SECS)60How long a directory account’s successful argon2 verify is cached, so a repeated correct password skips argon2 on the next request (issue #1067 — REST re-authenticates every request, so without this a busy directory account pays a ~19 MiB argon2 verify per request). Set 0 to disable. A security trade-off, not a tuning knob: bounds how long a revoked credential could still authenticate if a change ever reached the node outside UserStore’s own methods — today nothing does, since ALTER USER … SET PASSWORD/DROP USER/grant changes invalidate synchronously regardless of this value, so the practical window is zero. See Authentication & TLS.
--max-reads-per-user <n> (PATINADB_MAX_READS_PER_USER)0 (unlimited)Cap concurrent reads per authenticated principal, on both surfaces — one user’s burst of expensive read procs can’t exhaust the blocking pool.
--max-writes-per-user <n> (PATINADB_MAX_WRITES_PER_USER)0 (unlimited)Cap concurrent writes per authenticated principal, on both surfaces. Not redundant with the per-database write lock: with pipelined writes (on by default) a write’s resolve runs off that lock on the blocking pool, so one principal can otherwise hold as many concurrent resolves as --max-concurrent-requests allows. Earns its keep on a multi-tenant node (--rbac-closed); with a single trusted backend it bounds the same set as the node-wide cap.
--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. An unauthenticated connection never spends this budget (see the note below, issues #440/#730).
--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.
--bolt-auth-timeout-secs <n> (PATINADB_BOLT_AUTH_TIMEOUT_SECS)30Close a Bolt connection that has not successfully authenticated within this long — a hard deadline, armed once at connection start, that no client message can reset (unlike --bolt-idle-timeout-secs above). See the note below (issue #730). 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. Since issue #1069 this bounds only ROUTINE entries — every denial + ddl.user/ddl.admin/rotate-*/retire-* is exempt (see --audit-max-protected-entries). 0 = unlimited.
--audit-max-protected-entries <n> (PATINADB_AUDIT_MAX_PROTECTED_ENTRIES)1,000,000A SEPARATE hard safety-net cap on PROTECTED entries only (issue #1069 review follow-up — “protected means long-lived, not immortal”). Measured ~0.39 GiB at the default; see Authentication & TLS. 0 = unlimited (explicit opt-out).
--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 the unauthenticated-Bolt-connection lifecycle (issues #440/#730): --max-bolt-connections bounds only authenticated sessions — its permit is acquired at the instant a connection first authenticates (BoltConn::admit_connection), never at TCP accept, precisely so a flood of connections that never authenticate can’t starve real clients of it. Every phase BEFORE that point is instead bounded in time, end to end, with no gap: --bolt-handshake-timeout-secs covers accept → the Bolt version handshake, and --bolt-auth-timeout-secs covers the version handshake → first successful authentication — the latter closes a residual the (message-resettable) --bolt-idle-timeout-secs cannot: a connection that never authenticates but keeps itself alive forever with cheap pre-auth traffic (a bare HELLO, or any message answered without auth) that repeatedly resets the idle clock. Unlike the idle-read timeout, the auth deadline is armed once as a fixed instant and is never reset by any client message, of any kind or cadence.

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.

Raft timing and the round-trip envelope

Four env-only knobs set this node’s Raft timing, plus one stall bound for snapshot transfers (issue #1068). Leaving them unset gives the LAN-calibrated defaults the server has always used, so an unset environment behaves exactly as before.

VariableDefaultWhat it bounds
PATINADB_RAFT_HEARTBEAT_MS250Heartbeat period and the timeout on every append_entries RPC
PATINADB_RAFT_ELECTION_TIMEOUT_MIN_MS750Election timeout, lower bound
PATINADB_RAFT_ELECTION_TIMEOUT_MAX_MS1500Election timeout, upper bound
PATINADB_RAFT_INSTALL_SNAPSHOT_TIMEOUT_MS300000The snapshot resume-probe and the post-upload install/response wait — since issue #1068 it no longer caps the streamed upload itself
PATINADB_RAFT_SNAPSHOT_PROGRESS_TIMEOUT_MS30000Abort a snapshot upload after this long with zero bytes flowing; total transfer duration is unbounded while bytes keep flowing (issue #1068)

Give every node in a cluster the same values. They encode a shared assumption about the network, not a per-node preference.

The first one decides which peers can replicate at all. It is not only the heartbeat period: every append_entries — payload as well as heartbeat — is abandoned if it does not complete within it. That one number is therefore the whole budget for request upload plus round-trip delay plus the follower’s log fsync plus the response. Small heartbeat packets fitting the budget do not prove that a bulk-write payload or catch-up batch will fit. Account for the encoded body size and available throughput, and tune the entry/body bounds below together with the timing envelope; test both steady writes and catch-up. A peer that cannot answer inside the budget never acknowledges an append, so the leader’s matched index for it never advances and nothing commits through it. This applies to learners exactly as it does to voters.

This failure is silent, and that is the dangerous part. Followers reset their election timer when a heartbeat arrives — one way, far inside the election window — so no election storm draws attention to it. /mgmt/metrics reports a stable leader and healthy members. Writes that need the distant peer simply never commit, and a learner behind such a link stays empty indefinitely. If you are debugging “the cluster looks fine but writes to it hang”, check this budget against your real round-trip times first.

Measured under issue #1066 on a 3-node cluster with both follower links delayed (injected one-way delay against the default 250 ms budget):

Injected delayWrites committed
100 ms3 / 3
200 ms3 / 3
300 ms0 / 3

It is a cliff, not a slope — nothing degrades between 200 ms and 300 ms, it stops. Raising only PATINADB_RAFT_HEARTBEAT_MS to 2000 ms made the identical cluster commit 3/3 again at the same 300 ms delay, which is what identifies the budget as the cause rather than latency in general.

Two caveats on those numbers. They come from latency injected into the peer transport, not from a kernel netem link: the request leg is delayed, with no jitter, reordering or packet loss, so treat them as the budget boundary rather than as a model of a real WAN. And raising the budget is not free — it buys reach by making every failure detection correspondingly slower, so raise the election timeouts with it (keep the election window above roughly three times the heartbeat, the standard Raft guidance; --config-check complains if you do not).

Rough guidance, given a 250 ms default: a same-continent link (on the order of 80–120 ms round trip) fits comfortably. An intercontinental one (roughly 200–350 ms) sits on or beyond the edge and needs a raised budget — and every write then costs that round trip, so consider whether a stretched voter set is what you want, or whether a local read replica per region serves you better.

And PATINADB_SNAPSHOT_LOGS_SINCE_LAST (env-only, default 5000, 0 or an unparseable value = the default) — how many applied Raft log entries trigger a snapshot build on a node. The default is openraft’s own, so leaving it unset changes nothing.

Worth relating to your write rate, because the two halves are set by different things: how often a snapshot is built follows your traffic, while how much it costs follows your database. At 10 writes/s the default fires roughly every 500 seconds — about 173 builds per node per day — and each build streams that node’s graph. Raising the value trades a longer Raft log (more log disk, and a follower that falls far behind has more entries to replay before it catches up) for fewer builds; lowering it does the reverse. Measure a build on your own data before changing it: issue #1099 measured 0.77 s for a 60 000-vertex graph on NVMe, but that number is about that graph, not yours.

And PATINADB_PEER_RPC_ENCODING (env-only, json default) — the outbound peer-RPC transport encoding. Every node’s /raft/append and /raft/vote receiver accepts BOTH legacy JSON and the framed-postcard encoding (sniffed per request from Content-Type, answered in the request’s format), so opting any subset of nodes into postcard can never split the cluster. postcard switches this node’s sender to the compact framed-postcard encoding; the JSON default is unchanged (a project-wide default flip is deferred — patinaDB is not live in production). Receive-side format mix is observable via patinadb_raft_peer_rpc_received_total{rpc,format}.

And PATINADB_MAX_SNAPSHOT_BYTES (env-only, default 8 GiB, 0 = unlimited) — the receive cap on the streamed inter-node snapshot upload (POST /raft/snapshot). Since issue #1246 it windows a single upload attempt: over the cap the attempt aborts with 413 but the partial file is KEPT, and the sender’s probe/resume chain continues from that offset — so a snapshot larger than the cap (a 60M-element fixture produces a ~19.5 GiB compressed snapshot, measured in the #1145 rejoin drill) installs over several attempts instead of never. It exists because axum’s DefaultBodyLimit is a no-op on that streamed route. Watch patinadb_raft_snapshot_receive_rejected_total{reason="over_cap"}: a counter that climbs without the follower catching up means the chain is not converging. See High Availability.

And the replicated-entry size bounds (issue #839), both env-only:

  • PATINADB_MAX_ENTRY_BYTES (default 48 MiB, 0 = disabled — NOT recommended) — the proposer-side cap on a single Raft entry’s peer-wire size. A statement whose resolved write would frame an AppendEntriesRequest body over the followers’ /raft/append receiver cap is rejected before anything is proposed, with a non-retryable client error (Neo.ClientError.Statement.ArgumentError / HTTP 400) naming the limit — an oversized entry that DID reach the wire would be 413’d by every follower, retried forever by openraft, and would wedge the cluster into elections. If you hit this limit, split the write into smaller batches: CALL { … } IN TRANSACTIONS OF <n> ROWS for Cypher, smaller UNWIND batches from the driver, or chunk_rows on the bulk-upsert endpoints.
  • PATINADB_RAFT_MAX_BODY_BYTES (default 64 MiB, 0/unparsable = default; never unbounded) — the receiver-side body cap on the peer /raft/append//raft/vote//raft/snapshot endpoints. Raising it gives bulk workloads more headroom, but it must be set identically on every node (a follower with a lower cap than the leader’s entries 413s exactly as before), and PATINADB_MAX_ENTRY_BYTES should be raised with it, staying comfortably under this value. This knob is also what the sender budgets its append bodies against, so that a batch of individually-legal entries cannot exceed the cap when a follower is catching up (see Batched appends) — on a link slower than ~1.9 Gbps, lower it so one budgeted body still crosses the wire inside Raft’s 250 ms RPC deadline.

Admission counts the resolved request in both supported encodings (Postcard and JSON), retaining no complete output body for sizing, and stops when either exceeds the entry cap. JSON escaping and structural overhead count toward it; a small Postcard representation is not enough to pass. The check runs after request construction and does not limit transaction heap, serializer-owned scratch or the buffers used for actual persistence/transport. Leave room for the Raft entry and RPC envelope above the request; equal entry/body settings provide no envelope headroom. The 48/64 MiB defaults and explicit 0 behavior above are unchanged.

The two are an ordered pair, not two independent dials: PATINADB_MAX_ENTRY_BYTES ≤ PATINADB_RAFT_MAX_BODY_BYTES. Above the body cap the entry cap cannot protect anything — the guard waves through an entry every follower will 413. --check-config fails (exit 1) on a misordered pair; a node that boots with one warns loudly and clamps its effective entry cap down to the body cap rather than refusing to start.

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-addr → bolt_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

The four RFC 0042 query-engine switches are on by default, so the dry run prints their effective modes even when none is set, marking defaults and auto modes that are inert because the cost model is off. A set value outside a switch’s grammar is flagged too; for these switches the runtime selects off, not the default:

Query engine switches (RFC 0042; read once per process)
  PATINADB_COST_MODEL                off (set)
  PATINADB_FACTORIZE                 on (default)
  PATINADB_WCOJ                      off (set)
  PATINADB_CSR                       auto (default, inert: the cost model is off)

It also cross-checks the peer-wire size caps as an ordered pair (each value parses fine on its own; only the comparison catches a misordering) — see PATINADB_MAX_ENTRY_BYTES above.

On/off knobs: two different grammars

Flag-shaped knobs come in two kinds, and the dry run now says which one you are looking at, because they behave oppositely for the same value:

  • Boolean knobs — PATINADB_RAFT_GROUP_APPLY, PATINADB_PIPELINED_WRITES, PATINADB_PARTIAL_TIMETRAVEL, PATINADB_INDEXED_SNAPSHOTS, PATINADB_IMPORT_SORTMERGE_PARALLEL, PATINADB_PLAN_CACHE, PATINADB_SYNC_SNAPSHOT. 0, false, off, no turn them off (case-insensitive); 1/true/on/yes turn them on; unset — or set to an empty value — means the knob’s own default. The dry run renders the verdict (false → off).

  • Negated boolean knobs — PATINADB_NO_PARSE_CACHE, PATINADB_DISABLE_COMPRESSION. Same grammar, but read the name: a truthy value turns the named negative on, i.e. it disables the feature (PATINADB_NO_PARSE_CACHE=on means “no parse cache”). A falsy value, an empty value, or leaving it unset all leave the feature enabled. These are still ordinary booleans — unlike the presence switches below, =false does the sensible thing.

  • Presence switches — every PATINADB_DISABLE_* kill switch, plus PATINADB_TRAVERSAL_LIVENESS_PROBE. These are presence-based: any value turns them on, including 0 and false. To turn one off you must unset the variable, not assign it a falsy value. The dry run spells this out next to the value rather than pretending it is a boolean:

    PATINADB_DISABLE_OBJECT_CACHE      false → ON (presence-based: any value,
                                       INCLUDING '0'/'false', enables it —
                                       unset the variable to turn it off)
    

    PATINADB_TRAVERSAL_LIVENESS_PROBE is read once per server process, at the first match iterator, and inherited by every traversal step. Changing it requires a restart (as for any process environment variable).

    If your deployment tooling emits VAR=false for a disabled feature, that will enable these — omit the variable instead.

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_UPSERT_ELEMENTSenv5000000Cap on nodes + edges a single JSON subgraph upsert (POST /mgmt/upsert-graph, RFC-0020) may resolve — checked before the O(n) resolution structures (by_label, per-label Vec<UpsertRow>, the resolved DeltaOp batch) are allocated, so an over-cap request fails in O(1) instead of amplifying the request body into many times its wire size. Admin-only endpoint (defense-in-depth, not a remote/unauth DoS path). Over the cap: split the load with Dataset::upsert_subgraph_chunked / the REST request’s chunk_rows field.
PATINADB_RESTORE_OPS_CHUNKenv5000Operations per replicated entry when a restore (CALL patinadb.restore('<id>') or POST /mgmt/restore) proposes its delta. A restore larger than one chunk commits as several entries and is therefore not atomic — intermediate partially-restored states are observable. 0/unparsable falls back to the default (it does not disable chunking). See Engrams.
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_WASM_MAX_DECODE_DEPTHenv256Nesting cap for the result blob a WASM procedure returns (see WASM procedures). The host decoder is recursive and runs after the guest call returns, so fuel/epoch/max_wasm_stack no longer bound it; a hostile guest could emit thousands of nested one-element lists in a few bytes and overflow the host stack. Over the cap the call fails with a clean procedure error. A real proc result is 1–3 levels deep.
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. The reference deploy/ compose stack sets this to 16 (the code default stays unlimited).

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.General.TransientError 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).

Query parallelism (scan + aggregation)

Vector-index training has a separate shared pool: PATINADB_VECTOR_BUILD_THREADS accepts positive integers capped at 8 (1 is serial). The default is available logical CPUs minus one, bounded to 1–8; invalid values use that default. It is read on first training use, so restart after changing it. --check-config rejects invalid values (including zero) and reports the capped worker count. This does not parallelize redb writes or set a RAM budget. See Vector Search.

PATINADB_FACTORIZE also gates the RFC 0042 B3–B5 factor consumers: top-k within a factor, DISTINCT/existence over one factor, and cross-branch inclusion–exclusion counts. See query planning.

The RFC 0042 switches at a glance; details in the table below and in How the Query Planner Works.

KnobDefaultPurpose
PATINADB_WCOJautoClosing probes, sorted neighbour intersection/anti-intersection and base-adjacency cyclic joins. Values: exactly off, on or auto (case-insensitive). Unset or empty means auto; any other value, including Boolean aliases, means off. Read once per process. off restores the reference traversal. auto requires PATINADB_COST_MODEL (entry or join), otherwise it acts as off: closing probes need a cached degree, and intersections are admitted per candidate only when bounded domain observations predict a cost advantage; missing evidence keeps binary traversal. Anti-intersection and cyclic joins remain explicit-on only. Cyclic joins support up to sixteen vertex variables and retain physical relationship bags, uniqueness and label checks. Native query memory admission is required; refusal replays binary execution before output. Unsupported shapes retain binary traversal. Default auto since 2026-10-17 (RFC 0042 C0–C2, A4; #1442).
PATINADB_FACTORIZEonFactorized execution (RFC 0042 B2–B5): factor-local grouped aggregation, top-k within a factor, DISTINCT/existence over one factor and cross-branch inclusion–exclusion counts. Boolean grammar: unset or empty means on, 0/false/off/no mean off (restoring the reference operators); read once per process. Requires a query memory scope and proven independent fixed branches, or branches whose shared relationship types the runtime witness proof covers. Unsupported shapes and value domains retain the reference operator. With PATINADB_COST_MODEL=off every proven shape engages. With a cost model only the B2 per-pivot count path (count(*), count(x) and count(DISTINCT x) of the pivot or a branch end) is cost-admitted; other B2 shapes (sum, min, max, joined patterns) keep the flat aggregate, and the B3–B5 consumers have no cost admission. Default on since 2026-10-17 (#1442).
PATINADB_COST_MODELjoinCost model: off, entry or join (see below). Unset or empty means join; off and every other value mean off, the reference planner. Any value other than off also restricts PATINADB_FACTORIZE grouped aggregation (B2) to the per-pivot count path, engaged only when the cached degree catalog (CALL patinadb.stats.degrees()) predicts that it costs less than the flat aggregate; missing or refresh-due degree evidence and other B2 shapes keep the flat aggregate. The B3–B5 factor consumers are not costed. Read once per process. PROFILE records the estimates under agg.factorized.
PATINADB_CSRautoIn-memory adjacency cache for single-type fixed-length MATCH, variable-length traversal and shortest paths. Accepts off, on, auto and Boolean aliases. Unset or empty means auto; any other value means off, the reference reader. auto requires PATINADB_COST_MODEL (entry or join), otherwise it acts as off; it admits only deduplicating variable-length traversals whose estimated expansions amortize the build; fixed-length MATCH, shortest paths and PATINADB_WCOJ intersections retain the reference reader. Missing degree data requests bounded background statistics. Builds asynchronously; unbuilt, dirty or over-budget data uses the existing reader. Takes one ninth of the entry-cache allowance (under auto, only once a traversal was admitted); the existing levels share the remainder. PROFILE reports traversal.source. Performance is workload-dependent and not generally qualified; default auto since 2026-10-17 (#1442). No persisted format change.
PATINADB_QUERY_THREADS1 (off)Per-query execution-thread cap, including the caller, clamped to 1–32. Values above 1 enable eligible read-only scans, fixed traversals, mergeable aggregates, bounded top-K with SKIP, and seeded MATCH Apply over ordered outer rows. Applicable serial path-count, grouped path-count, covering-input and deferred-prefix specializations retain priority at every thread cap. Results and ordering retain the serial contract. Secondary and multiple required entry labels are supported when the inverse index is ready. sum/avg and unsupported shapes remain serial; merge enforces the usual aggregate caps.
PATINADB_QUERY_POOL_THREADSAvailable parallelism, clamped to 1–32Maximum persistent background query workers per process, independent of Tokio and vector training. Application caller threads are additional. Read once when initialized.
PATINADB_QUERY_MORSELS_PER_WORKER1Target scan morsel count per primary/secondary segment is this factor times the per-query worker cap; factor clamped to 1–64. A 1024-row sizing heuristic reduces partitions on small inputs. Plain scans use UUID bounds; traversal drivers use key-count sampling. Edge-range partition limits are unchanged. Tune against the workload.
PATINADB_PAR_AGG_MIN_ROWS100000Minimum estimated label cardinality for the existing scan executors; top-K/Apply admission instead estimates reachable traversal work from outer rows and bounded fan-out samples. With PATINADB_COST_MODEL entry or join (the default since 2026-10-17), calibrated work admission (estimated serial work above four times the measured dispatch, morsel and merge overhead) replaces this floor for aggregates; row-producing pipes need both. In model-off mode (PATINADB_COST_MODEL=off), zero forces the work threshold but retains shape and specialization exclusions. The default is an unverified scheduling heuristic, not a measured crossover; results retain the serial contract.
PATINADB_PAR_FANOUT_MIN_EDGES50000Minimum actual average first-hop key count per anchor before adjacency prefixes split at sampled keys (#805, #1442). Preparation counts pinned keys for small eligible anchor sets; below the threshold, execution uses anchor-only ranges. Edge ranges are capped at four times the query thread setting. Unverified performance heuristic; never affects results.

--check-config validates PATINADB_QUERY_POOL_THREADS and PATINADB_QUERY_MORSELS_PER_WORKER with the runtime’s exact unsigned integer grammar and reports their clamped values. Zero becomes one; values above the respective maxima become 32 or 64. Whitespace, suffixes, negative values and integer overflow are rejected by the check instead of silently using defaults.

The gate declines to serial for anything it cannot prove safe: writes, AS OF time travel, subqueries/CALL {}, variable-length traversal, pattern-property or index-seek sources, secondary entry labels without a ready inverse index, sum/avg aggregates, and DISTINCT projections in the chain. Historical #781 measurements with the scoped scheduler (not the current pool): on the 8.4 GiB 1M-ticket benchmark database (warm page cache, 16-core box, min-of-3): a full Ticket scan with count(*) + count(DISTINCT title) + min/max went from 3 992 ms serial to 2 702 ms at 4 threads (1.48×) and 2 204 ms at 8 threads (1.81×); 2 threads is slightly slower (0.93×) than serial, so those historical results do not establish a crossover for the current scheduler. See RFC 0041 for the later coordinator measurements and their limits.

Covering reads

The #1399 covering path is selected automatically for eligible broad, single-label integer-property aggregates. PATINADB_READ_COVERING=off is a diagnostic off switch; on permits selection but never overrides eligibility. Explicit parallel-query configuration retains existing parallel/serial admission until its competition with covering is qualified. It keeps UUID encounter order, including intermediate overflow behavior, using bounded external sorting when needed. Float, mixed-type and wide-text columns, selective patterns, and unproved shapes retain the existing path. Existing grouped-count fast paths keep precedence. Limited synthetic qualification demonstrates the supported narrow-property benefit under constrained total memory; it does not qualify every workload.

KnobDefaultPurpose
PATINADB_READ_COVERINGonAutomatic guarded selection; off retains existing access for diagnosis.
PATINADB_READ_COVER_RUN_BYTES8388608Positive plain integer bytes for the run buffer, clamped to 64 KiB–64 MiB. Zero/invalid uses the default.
PATINADB_READ_COVER_SPILL_BYTES1073741824Positive plain integer logical scratch limit including merge output. Zero/invalid uses the default.

TMPDIR chooses scratch placement. Merge fan-in is limited to 16 readers and one writer. Scratch cleanup occurs on normal completion, cancellation and errors. The limits cover this operator’s auxiliary buffers and encoded scratch bytes; filesystem allocation overhead, file cache, backend cache, aggregate group/DISTINCT state and concurrent queries require their own memory/disk headroom. No total RSS limit follows from a run budget. An estimated reorder larger than the scratch limit retains existing access before preparation. Actual scratch use remains checked. If preparation exceeds its scratch budget or cannot allocate/write scratch, it discards preparation and retains existing access before emitting rows. Storage/index errors, cancellation and failures after covering execution starts still propagate; ordinary aggregate limits remain.

EXPLAIN reports agg.covering_integer_uuid with row/byte estimates when the candidate is selected, or a fallback reason. It separates estimated encoded key bytes, reorder records, logical merge I/O and peak scratch. The secondary value payload, page overhead and actual primary-read bytes are not included. These estimates are not measured physical I/O costs or latency predictions. The 4096-row minimum is an admission heuristic, not a calibrated crossover. PROFILE separates the planned access from observed aggregate execution, including fallback reasons, actual covering record count and memory/external reorder. Compare automatic selection with off under the same total RAM budget when tuning a workload. See Query Planning.

Bulk import (sort-merge)

Offline bulk loads (patinadb import) use external sorting for both fresh stores and re-imports, including --skip-ref-check. Mutation spools preserve last-write-wins and removals. Existing stores maintain indexes incrementally; fresh imports with duplicate IDs rebuild from the final primary state. Missing edge endpoints are still skipped by storage. Ordinary online transaction/replication semantics are unchanged. The old import opt-out and row-threshold selectors have been removed.

KnobDefaultPurpose
PATINADB_SORTMERGE_SCRATCH_DIR$TMPDIR/patinadb-sortmerge-<pid>-<n>Directory the external-sort spools spill to.
PATINADB_SORTMERGE_RUN_BYTES128MiBPer-spool in-memory run buffer before spilling to scratch.
PATINADB_SORTMERGE_FILL_BATCH200000Entry-count cap for sorted-spool consumer batches.
PATINADB_SORTMERGE_FILL_BYTES67108864 (64 MiB)Key/value allocation-byte cap for sorted-spool consumer batches and direct snapshot-restore buffers (#1398).
PATINADB_SORTMERGE_DELTA_WORK_BYTESAutomaticAdditional estimated-work budget per sorted incremental-delta transaction; does not affect fresh dense builds or snapshot filling.
PATINADB_INDEX_BUILD_SORT_KEYS8000000Key-count cap for deferred index rebuild runs.
PATINADB_INDEX_BUILD_SORT_BYTES67108864 (64 MiB)Retained key allocation-byte cap for deferred index rebuild runs (#1398).

The fill/rebuild byte knobs accept positive integer byte counts; zero or invalid values retain their defaults. Byte and count caps both apply. A single oversized entry is flushed alone. These controls are not total-memory guarantees: allocator, backend/cache and merge buffers need additional headroom. See deferred build memory.

The delta-work budget accepts positive integer bytes. Unset, zero or invalid values select automatic sizing: 1/32 of the configured redb cache, clamped to 1 byte–64 MiB. Without an explicit or pool-derived redb cache setting, the calculation uses redb 4.2’s 1-GiB fallback. Each delta is charged the larger of its retained key/value capacity (including descriptors and the temporary mutation tag) and 16 KiB. These are scheduling estimates, not measured page allocations. The existing fill-byte and entry-count limits still apply, whichever is reached first; an individually oversized entry makes progress alone. The cache setting is a sizing hint, not a reservation or a guarantee about an already-open database after a runtime cache-budget change. See delta tuning.

Fulltext construction also uses PATINADB_SORTMERGE_RUN_BYTES. Its scratch directories are private temporary patina-fulltext-* directories under the process temporary directory; the import-specific PATINADB_SORTMERGE_SCRATCH_DIR does not redirect them. Set TMPDIR to a suitable disk before starting the process. The run buffer is not a total RAM limit; merge readers, the largest posting list and database caches need additional memory. Very small buffers create more runs and require more open file descriptors during merging. See Full-Text Search.

Snapshot record encoding memory

Streaming historical snapshots, tag snapshots and anchor bases share an estimated 8-MiB input budget per encoding block, in addition to the existing 4096-record cap. Property values use the shared recursive byte estimator; borrowed records count too because their encoded output is materialized. One lookahead record may be retained, and a record larger than the budget is encoded alone without rejecting valid data. Record order, compression and wire framing are unchanged.

This is a scheduling estimate, not a total-memory limit: spare container capacity, the lookahead or oversized record, encoded output, codec contexts, allocator overhead and upstream database caches require additional memory. Multiple background writers have separate blocks. Shutdown is checked before and after reading each record, before encoding, before writing each record and before the end marker. A native read, compression call or filesystem operation already in progress cannot be interrupted by these checks. Publication and final durability still require successful completion; an aborted stream is not made visible as a complete snapshot.

Local checkpoint reuse during restart

During native startup, Patina may reuse its recovered local Raft checkpoint when the snapshot format, causal domain, applied log position and membership exactly match the current state. This avoids rebuilding that same checkpoint merely because peer versions have not yet been discovered. Checkpoints with mismatched state are not reused through this path; explicit repair retains its normal behavior. Sending snapshots to peers still requires the existing protocol compatibility checks.

A missing checkpoint, a moved data directory whose stored checkpoint path is no longer canonical, or a different state may still require a rebuild. The change does not establish a general restart-time guarantee or qualify the full large-database pressure soak (#1494).

Physical checkpoint copy pacing

PATINADB_CHECKPOINT_COPY_BYTES_PER_SECOND optionally paces physical Raft checkpoint copying on each node. Set a positive integer number of input bytes per second; unset or 0 retains the unpaced policy. Invalid values fail the checkpoint build with a configuration error. The setting is read when a build starts. A one-MiB burst is allowed; idle time does not accumulate extra credit.

This counts copied key/value bytes, not physical disk traffic. Native B-tree work, page-cache writeback, packaging, final syncs, historical tag reconstruction and other I/O are outside this limit. It is per build, not a shared-device cap. It does not change the snapshot cut, final durability or ordinary write policy. Cancellation remains checked while pacing; an in-flight filesystem call is not preempted.

Use this when simultaneous checkpoints compete with foreground commits on a shared device. A lower rate trades longer checkpoint creation and potentially longer log retention or learner onboarding for less copy pressure. Choose it from mixed-workload measurements on the actual storage and node count. This is an operator control, not a latency or memory guarantee; full low-memory cluster qualification is still pending.

Physical checkpoint package pacing

PATINADB_CHECKPOINT_PACKAGE_BYTES_PER_SECOND separately limits copying the completed native checkpoint file into its hashed Raft snapshot package. Set a positive integer byte rate; unset or 0 preserves the unpaced copy. Invalid values fail the snapshot build. The setting is read for each file copy. It counts actual file bytes, including any sparse regions represented in the snapshot stream, with a one-MiB burst and a bounded 64-KiB read-ahead buffer. Waiting checks shutdown; in-flight filesystem calls are not preempted.

This controls a different phase from native key/value checkpoint creation. It preserves framing, digest verification, file-length checks, final sync and atomic publication. It does not pace history serialization, native checkpoint construction, final syncs, restore, or ordinary requests. It is per file/node, not a shared-device cap or a foreground latency guarantee. Lower rates extend snapshot completion and log retention. Select rates with mixed-load measurements; the full low-memory soak is still unqualified.

A physical snapshot build releases its read views of the live databases before packaging starts. It first writes the history and restore streams to an unnamed temporary file in the snapshot directory, then writes every database’s checkpoint, and only then packages them. A slow package therefore no longer keeps old pages of the live stores pinned. The cost is temporary disk space: all checkpoints and the spooled streams exist at once until the build ends, where previously only one checkpoint existed at a time. The package bytes and their order are unchanged.

Historical tag-base fill pacing

PATINADB_TAG_BASE_FILL_BYTES_PER_SECOND controls the direct primary-tree fill used when a background historical tag needs a reconstructed snapshot base. A positive integer selects native key/value input bytes per second; unset or 0 keeps the existing unpaced policy. Invalid values fail the background build. The four direct buffers share one budget per reconstruction and, when enabled, flush at no more than one MiB of retained input each (a larger single entry is still accepted alone). Existing smaller byte and entry caps still apply.

The rate counts data after decompression and native encoding, not the compressed snapshot file size. Waiting happens before a batch enters the native writer, and checks cancellation. Snapshot contents, publication ordering and terminal durability are unchanged. A lower rate delays tag readiness and can hold the history read lease longer; it is not a foreground latency guarantee.

This is a scoped background-work control, not a disk bandwidth or process-memory limit. The eligibility scan, edge-sort spools, metadata, delta replay, final snapshot serialization and fsync are outside this rate. Queryable AS OF restore, learner installation, live writes and legacy/genesis reconstruction fallback retain their normal policy. Use mixed-workload measurements for rate selection; low-memory full-cluster qualification remains pending.

Periodic snapshot scan pacing

PATINADB_SNAPSHOT_SCAN_BYTES_PER_SECOND controls decoded-record work for periodic asynchronous history snapshots. The default is 16 MiB/s per process; a positive integer selects another byte rate and 0 disables pacing. The node reads the setting once on first use; restart to change it. Invalid values fail the background snapshot build before publication.

Concurrent periodic builders share one budget and one MiB of burst credit, booked in arrival order so one builder cannot starve another. Each record is charged immediately after it is read, before the next iterator pull or encoding, using estimated decoded size with a 4-KiB minimum. Compressed output size would undercount wide properties. A pending record is charged only once. Its size is not known before reading, so one oversized record may exceed the burst; it remains valid. Budget waits poll shutdown every 50ms. An already running native read, codec or filesystem call is not preempted.

This prevents periodic snapshot workers from scanning freely just because their output compresses well. It is not a physical read/write bandwidth, dirty-page, RSS or latency bound: index traversal, cache misses, filesystem amplification and final fsync remain outside the estimate. Reads can flush dirty redb cache pages. Foreground exports/genesis/recovery, explicit tag exports, query scans, checkpoint copies and their existing budgets retain their policy. The record format, ordering, atomic rename and durable publication barriers are unchanged.

A lower rate delays snapshot readiness and keeps the read snapshot pinned longer, which can increase copy-on-write space and retained history. Size the budget for all nodes on the device and observe both foreground latency and disk growth. The default is an admission policy, not evidence of a passing full-size soak.

Private snapshot native-write pacing

PATINADB_SNAPSHOT_WRITE_BYTES_PER_SECOND optionally limits admission of native file writes for private Raft checkpoints and historical tag-export bases and overlays, and the copy of each completed checkpoint into its Raft snapshot package. Set a positive integer byte rate; unset or 0 disables this additional budget. Invalid values reject private-file creation. The process reads this setting once on first use; restart the node to change it.

All these private files in one process share a single rate and one-MiB burst. Each native write is charged, including redb page rewrites and commit writes. Creating another file does not reset the budget. Admission waits occur before at-most-64-KiB native calls, observe shutdown and release the budget lock before I/O. Writers are booked in arrival order and wait without holding the budget lock, so a long checkpoint cannot starve a small tag export: each gets a share of the rate. A syscall already running is not preemptible. Cancellation fails the private artifact; it never reports an unwritten chunk as successful.

This complements the input limits above: B-tree and copy-on-write work may write more bytes than the incoming records. It is not a physical-device bandwidth limit: filesystem metadata/write amplification, reads, other .snap serialization, live graph/Raft/audit writes and final sync waits are outside it. The checkpoint package copy is charged per 64-KiB chunk before it is written, after the optional per-file package rate above; with this budget unset and no package rate, the copy stays unpaced. Ordinary restore and query mirrors also retain their normal policy. Syncs, file locking and publication order are unchanged.

A lower rate prolongs checkpoint/tag readiness, pins and log retention. Select it with the complete node count and mixed workload; multiplying the setting by the number of nodes still does not yield a device-I/O guarantee. The severe low-memory three-hour soak remains pending; this control is not a latency or memory guarantee.

Kill switches (rollback valves)

Presence-based env kill switches for recent read-path accelerations — set the variable to any value (including =0) to disable the optimisation, e.g. for A/B benchmarking or if you suspect a regression. Results are identical either way; only the serving path changes.

  • PATINADB_DISABLE_QUERY_CTX_REUSE (issue #632) — disables the per-open schema-metadata cache and the per-Dataset cached ProcContext (both are rebuilt per query instead).
  • PATINADB_DISABLE_EXTRA_LABELS_NEGCACHE (issue #631) — disables the negative cache on the extra-labels hydration probe (the per-row “does this vertex have secondary labels” check).

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 client statements node-wide (REST and Bolt); excess shed with 503 / a retryable Bolt error. 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).

Transaction and query memory budgets

These budgets are active by default on the server: it installs one work-memory governor at startup from the automatic or configured values below. Embedded library use has no governor unless the caller installs one.

RFC 0036 server integration, bounded Q/TX/foreground controls and the original 120-second default smoke pass at 4bfb2d3e. The separate three-hour default-pressure run passes the agreed runtime gate after independent and final review, with its observed cap overshoots and sampling gaps. Performance measurements are complete; performance acceptance remains open. The new write matrix is reviewed with an unresolved COMMIT p99 increase; the completed 4bfb retained-tail comparison adds a higher first-read latency. Main wires the four settings below through ENV, CLI and YAML, with CLI > ENV > YAML > automatic defaults and the same resolution used by --check-config. Source tests and read-only config checks do not qualify a complete node under memory pressure. The existing outer settings are listed in Cache memory budget.

With Main’s shared Limits.memory_governor, normal writes also check their already resolved request before Raft proposal. Checked 6 * counted_data_bytes + 64 must fit the configured Apply allowance. The count covers retained container layouts, accessible string/label capacities, nested values, old conditional preimages, idempotency/CAS data and Anamnesis operations. Opaque text uses visible UTF-8 bytes plus a header; this is a coarse allowance, not a query-text prediction or an exact heap/RSS bound. Existing producer admission runs earlier and keeps its owners; their accounted credit is not multiplied or reserved again. A separate foreground allowance covers the check’s temporary nested-value descriptors.

A terminal refusal occurs before the rejected request/chunk is proposed, preserves TX/Engram atomicity, and does not replay it. For chunked imports, successful earlier chunks remain committed; the first terminal proposal error prevents later chunk proposals even with ON ERROR CONTINUE. Ordinary resolvable query errors retain their existing continuation behavior. Already committed Apply/replay is not rejected by this gate. Qualification remains open; see the normal-write scope.

Governed Bolt streaming reads

Bolt streaming reads now use their statement’s query allowance throughout the blocking Core worker, with native read fences for live and reconstructed plain historical stores. Existing covered parameter, expression, sort and materialized result paths share that scope. There is no new setting or per-row reset.

The context retains expression and binding credits until the worker exits, including dead intermediates. Long streams can therefore be refused even when their exact live heap would fit. Sort scratch keeps its own shorter lifetime. Encoded frames remain separately owned and release their credits as PULL/DISCARD or disconnect releases their storage. RESET/disconnect closes admission; worker data stays charged until it actually drops, without a synchronous-join guarantee.

Coverage is still partial: L3/L4 RowTable-hit outer copies and the ordinary lazy stream’s hydrate_row property containers/recursive scalar hydration are not admitted by merely installing this context. Native/cache/transport internals and other uninstrumented producers also remain separate. Focused controls and broad regression validation pass; performance qualification for this change remains open. See the streaming scope and controls. The completed bounded comparison at 08bd4971 still exceeds the first-read overhead objective. A later change prepares existing cache-policy metadata during enabled governor construction. Its bounded query/retained comparison now completes with improved first-read medians, but adverse individual blocks remain; a general 10% bound and final-source pressure qualification remain open. See the measured results and source bindings.

Earlier integration scope

The following producer notes record the separately qualified source increments. Their statements that an individual increment added no switch/default describe that earlier scope; the server now supplies the shared governor by default. They remain coverage boundaries, not a full-memory or performance guarantee.

Core reservation and shared Raft operation ownership are implemented. An explicit Rust Limits.memory_governor can additionally admit Bolt explicit-TX operation capture/retention and encoded RECORD buffers through PULL/DISCARD. Encoding borrows the Core table and reserves its own iterative scratch, output, framing overlap and shared byte owner before allocation. Partial delivery retains those credits; terminal delivery destroys its wire storage before SUCCESS. Assembled Bolt message storage is also reserved before reading each chunk; decoded PackStream layouts retain their permits with the parsed request. Explicit RUN input and result work use the same statement allowance. An oversized incomplete frame aborts unproposed work and closes the connection; a complete frame rejected during decoding allows RESET afterwards. Explicit-TX RUN conversion additionally reserves parameter map storage, keys, nested containers/strings, expanded byte lists, point coordinates and named-zone storage. Worker clones share one immutable owner and its credit. Conversion refusal aborts unproposed TX work and allows RESET. The admitted private Core resolve paths also reserve runtime parameter maps and fresh nested values before binding. Their returned owner keeps parameter backing charged through worker and result aliases, until RECORD encoding has produced independent bytes. The central RowTable hydrator additionally reserves its concrete output containers, property decoding, labels and fresh scalar values as it builds them; the result owner retains their credit through delivery. Oversized output can therefore refuse RUN even when the mutation payload fits. This preserves the private resolve boundary and does not create a partial Engram. Other output producers still need coverage; this introduces no new switch or automatic default activation. Explicit admitted Bolt COMMIT also reserves its new CAS token containers, fresh values, label arrays and sorting scratch; local request/entry clones share those data owners. Old vertex/edge properties for the final CAS tokens are now decoded directly into admitted storage. Their strings, containers and iterative decoder scratch are reserved before allocation; read/decoder errors propagate instead of becoming missing properties. Refusal occurs before proposal and reports no effect. Constraint catalog construction, native page/cache/registry storage, encoded overlay read buffers, the interned label arena, fallback preimages and other write paths remain outside coverage. A large old property can therefore make COMMIT refuse even when the small update itself fitted RUN. The wire schema and server defaults are unchanged; full qualification is still pending.

The common column-oriented QueryResult hydrator also uses the active development query scope. Before constructing its output maps, row arrays and scratch, it reserves a coarse 4096-byte allowance per actual input cell. This is a counting policy over rows already produced by execution, not a prediction from Cypher. Repeated columns and freed scratch can be overcharged. The allowance is credit; it does not cause an allocation of that size. Stored properties and copied value payloads are admitted separately, and output credit remains with the result owner. The explicit development governor also connects ordinary REST data reads to this same execution scope. Covered working buffers can reject a query even when its final response would be small. Core callers opt in via query_read_with_memory; this data-read API rejects writes and fences native mutation of the live graph on the executing thread. Historical plain stores are fenced too; private lazy overlays keep their reconstruction behavior. Other threads may still write. The fence preserves atomicity on refusal; it is not a general procedure sandbox.

Result-cache hits reserve the same coarse container policy plus fresh payload copies before creating their QueryResult. Credit remains with the result owner after query sealing or cache eviction. This deliberately copies payload backing; its performance impact has not yet been qualified. Persistent cache admission, L4 decode buffers, earlier row/value producers and alternate emitters remain separate gaps. This introduces no new setting or production default activation.

With the explicit development governor, both expression evaluators now share admission for argument arrays, list/map expression containers, comprehension output and generated integer lists. The existing buffer budget checks actual requested capacity before allocation and old/new overlap before growth. A typed resource failure stops the admitted producer and reaches the normal query error boundary; no write is proposed by a failed private resolve. Argument slots release after the call. Returned container credit stays with the query/result owner, conservatively including dead intermediates until that owner drops. This can reject work sooner than exact live-heap accounting. Strings, recursive value clones, entity/row payloads and remaining producer paths need separate admission; these container credits do not establish complete coverage or an RSS bound. Existing row caps, semantics and error types remain. No new setting/default is activated.

The development governor now also applies one coarse allowance before scalar string production: 6 * work_bytes + 64, with checked arithmetic. This is a counting policy, not six times measured RSS. trim variants, string reverse, left, and each split piece use actual UTF-8 input bytes. ASCII case conversion uses the same byte count; non-ASCII lower/upper conversion uses three times input bytes to allow expansion. replace counts actual non-overlapping matches (also empty patterns) and uses the larger of input and output bytes. substring and right use four times input bytes to include their existing char-array scratch. A 500-byte ordinary string therefore reserves 3064 credits, without allocating 3064 bytes merely for accounting. Split also admits its growing list slots.

The shared arithmetic executor also applies this allowance to + string concatenation, using the combined UTF-8 byte lengths of the actual values. A mixed i64/f64/bool operand adds 512 work bytes for the existing scalar formatter before the sixfold allowance. That fixed allowance is a policy, not its measured RSS. The same path serves both expression evaluators. List concatenation admits its growing output slots and moves existing elements into them, preserving nested payload storage instead of recursively cloning it. Existing source buffers still need their own admission and remain separate from the new output slots. The query/result owner retains output credit. Consumed string operands release their own credits only after new output construction finishes; operands whose data may be moved into results retain their owners. Other producers can still retain dead intermediates conservatively. No parsing-based cost prediction, default or ENV switch is added. See concatenation validation.

Covered literal, parameter and scalar-binding copies in the expression executors now share runtime admission. Reading an existing string shares its EcoString backing (or copies its inline bytes) without a new expression payload charge (#1449). Source storage remains a separate obligation; newly computed strings still require producer credit. Recursive values reuse the checked container/payload copier, including map keys. Materialized table results keep fresh admitted payload copies so their delivery owner is independent of source rows. Inline strings allocate no heap payload, so the copier checks scope admission with zero payload credit; their bytes are covered by value/container storage. Heap copies reserve their fresh payload layout. String functions retain their existing producer allowance. The scalar dispatcher borrows its arguments and admits selected/copied output instead of eagerly cloning its first argument. Consumed list/map indexing moves the selected payload. Output credits remain with the query/result owner, conservatively including dead values. This is scoped coverage; other row/AST/value copies and producers remain open. See runtime value-copy validation.

The augmented runtime rows used by both expression evaluators for local bindings now reserve their actual container capacity and copied outer payloads before allocation. Mutating shared row backing first admits a fresh copy through the same helper. Lexical shadowing and evaluation order remain unchanged. Source storage and inserted values still require producer ownership; copied row credit stays with the enclosing query/result owner, conservatively including dead values. Other row producers remain uncovered. This adds no keyword-based prediction or configuration switch. See runtime binding-row validation.

The library transformations and Unicode behavior remain intact. Credit is reserved before the call and retained with the query/result owner; dead temporary strings can stay charged until that owner drops. These estimates may reject work before its exact live heap reaches the limit. Existing value ownership, other conversions, remaining recursive clones, native memory and remaining producer paths are separate. All-scalar Unicode and real allocation-request probes support the policy for the tested toolchain and cases; allocator slack/RSS are not proven by those probes. Performance impact is unmeasured. No new knob/default is enabled.

The Bolt metadata correction adds patinadb_effect: "none" only for Neo.ClientError.Statement.ResourceExhausted when discarding an open unproposed explicit TX with no pending committed result, or in read-stream setup/execution. Other errors keep their existing metadata. This describes persistent mutation; read records may already have been delivered. Ingress/decode refusal while a committed write/control result is still pending does not gain this annotation. It does not convert a lost commit acknowledgement into a safe replay. The original bounded TX control exposed missing effect metadata and remains FAIL. A new bounded control at 4bfb2d3e completes strict query/TX/foreground refusal, no-effect, subsequent progress and five-node owner-release checks. The full run completes eight in-mix Q/TX controls and a separate post-mix foreground control with the same strict oracles; independent and final review accept this finite workload. Limits and retry policy are unchanged. See the boundary validation ledger.

The same development governor now covers runtime operation capture and pinned CAS/OCC for ordinary REST /cypher autocommit writes in both existing modes. Execution and old-value token growth share one query budget across pipeline attempts. Pre-proposal resource refusal reports HTTP 400 with Neo.ClientError.Statement.ResourceExhausted, retryable: false and patinadb_effect: "none". Outstanding worker/proposal owners keep their credits after the HTTP future drops. Ordinary REST data reads and writes also reserve converted parameter containers/keys/values and the worker query copy before allocation. Handler and workers share this immutable input; writes charge it to the same execution/CAS allowance. The /cypher HTTP collector reserves wire buffer growth from that same query scope before allocation, including streamed requests without Content-Length. Axum’s existing body-size limit (413) and JSON validation remain independent; exhausted memory gives the resource response above. Before the unchanged JSON decoder runs, an additional conservative allowance is reserved from that same scope and retained until the decoded request is destroyed. Successful decode releases only the separate wire buffer.

The JSON allowance is a byte-based accounting policy, not a Cypher cost prediction: wire_bytes * (6 + 3 * floor(log2(escape_count + 1))) + structure_bytes. structure_bytes starts at 512 bytes, adds 1024 per opening object brace or backslash, 128 per opening array bracket, and 256 per comma. Counts include quoted strings: the existing decoder can parse embedded JSON strings through its raw_value support. Escapes allow multiple live decoder buffers; Unicode-escaped markers are included conservatively. All arithmetic is checked. These are policy coefficients, not measured RSS ratios. Quoted punctuation, duplicate keys and ignored fields can be overcharged; the complete allowance remains reserved during conversion/execution even after parser scratch has been freed. Insufficient allowance produces the resource response before JSON parsing or mutation, so it can precede a syntax error. There is no new tuning switch for these coefficients.

Ordinary REST data-read responses also retain a budgeted byte buffer through HTTP body ownership and transport clones. The unchanged JSON serializer writes through the existing admitted vector: capacity growth is checked before allocation, including simultaneous old/new buffers. This charges actual buffer capacity; it does not allocate six copies of the estimated output. A growth refusal discards the partial buffer before sending HTTP 200 and returns HTTP 400 / resource exhaustion with retryable: false. This is an output-stage error, not the pre-dispatch patinadb_effect: "none" input contract. Completed writes and control responses keep their existing response path. No new switch or default is added.

Native transport frames, allocator overhead, rendered error bodies, downstream provenance copies, uncovered read operators/result emitters, JSON-tree construction, and other REST routes remain uncovered. Budgeted encoded bytes do not make the whole result pipeline bounded. The server supplies the shared governor from the ENV/CLI/YAML settings below; complete qualification remains open.

With the development governor, OCC now uses admitted hash sets for entity, property and replaced-label identities. This covers the candidate, each intervening Engram’s structural marks, and pinned CAS construction in both Bolt write modes. A pipelined candidate keeps its table credits while its in-flight registry entry or a conflict-check worker still owns it. Exhaustion is a pre-proposal resource error with no effect, not a conflict retry or optional-CAS fallback. A small write can therefore refuse while checking a large unrelated commit since BEGIN; retrying a new transaction after RESET starts from a fresh base. Retained constraint maps, label arrays, copied values, prefilter sets and additive UNIQUE tuples now use that allowance too. They keep their credits across history pages; replacing a map value releases its old payload only after admitting the new one. This keeps the existing final-value and additive-UNIQUE semantics. Emitted keys and their scratch tables/values are now admitted too; their owners keep credit after the accumulator is dropped, including while waiting in the pipeline. Composite-key variants are emitted individually with the existing expansion cap and numeric equality rules. Governed store-property reads for these checks now decode under the same allowance, retaining the source credit through key construction. Missing properties remain distinct from memory/read errors. Buffered final values are borrowed; explicit deletes do not read the live value again. Live and pinned read views retain their original boundaries. Decoded label IDs and label-result arrays in OCC and pinned CAS capture now reserve before allocation too. Missing vertices are distinct from read or memory failures. CAS retains the same admitted array after removing its primary label; membership/order semantics are unchanged. The retained list of intervening Engram IDs now admits capacity growth before allocation too, including temporary old/new-buffer overlap. Metadata strings are borrowed during the walk. Many small foreign commits can therefore exhaust the allowance even if each individual body is small; this returns a resource error before proposal, not a conflict retry. Governed OCC now also decodes each stored body directly into admitted operation/value storage. RAW bytes and metadata strings are borrowed; decompressed ZSTD output remains charged while decoding. Each body owner drops after its fold; the shared constraint context survives across bodies. A refusal remains a resource error before proposal. The native ZSTD context/window, tree/overlay/cache buffers, interned label arena and catalog source data still need separate admission. No additional toggle or automatic default is activated. These development controls are not a full memory-pressure qualification or a measurement of the PR’s performance impact. With the development governor, streaming Bolt output also reserves its encoded and framed bytes before allocation. Queued frames and transport writes retain their credits until sent/discarded; an idle consumer can exhaust the byte budget before the 256-row channel fills. A mid-stream refusal emits FAILURE after any valid prefix, without a successful completion/bookmark, and aborts an admitted explicit transaction’s unproposed writes. This reuses the existing RECORD encoder; it does not cover the Core executor’s earlier copies/rows or fixed channel storage. Control messages currently share foreground capacity too; a full pool can close the connection instead of admitting RESET. This development API does not meter handshake/dispatch, provenance, streaming executor parameter copies, per-evaluation value copies and result tables/operators, result metadata, mirrors, TLS/WebSocket internal buffers, native caches or committed replay. The server passes one governor to all explicit integration points; that wiring does not make this a qualified memory profile. Complete coverage and pressure qualification are still required before release.

Settings and automatic defaults

The defaults use effective RAM: physical RAM constrained by the process/container’s applicable cgroup limits. They do not grow with database file size. A large file can be read through bounded buffers; a small database can still receive a transaction with very large values. The own-heap planning budget leaves room for page cache, runtime and allocator usage; it is not a hard RSS or kernel-memory cap. Caching being off must not disable transaction protection.

Query text and database size cannot establish a reliable allocation bound. The governor admits covered actual buffer/container growth during execution; optimizer estimates may guide a plan but do not replace that admission. Existing values must be decoded under their allowance too, not decoded fully and checked afterwards. This is dynamic accounting, not a predicted maximum query payload.

Reservations include buffer capacity and temporary overlapping buffers, not just the final value or wire size. Bolt RECORD encoding starts with an admitted 256-byte output buffer and space for the row’s work descriptors, then admits further growth as needed. A deliberately tiny query budget can therefore reject even a small result. This uses the existing query/transaction/pool limits; there is no separate encoder setting.

Names and automatic formulas (integer bytes, rounded down):

ENVCLI flag / YAML keyMeaningauto value (default)
PATINADB_MAX_TX_MEMORY_BYTES--max-tx-memory-bytes / max_tx_memory_bytesLive accounted bytes across all statements of one client transactionmin(256 MiB, foreground / 4)
PATINADB_MAX_QUERY_MEMORY_BYTES--max-query-memory-bytes / max_query_memory_bytesStatement sublimit within its transaction, shared by parallel operatorsmin(128 MiB, transaction / 2)
PATINADB_APPLY_MEM_LIMIT--apply-mem-limit / apply_mem_limitProtected committed-apply, replay and recovery capacity25% of work
PATINADB_BACKGROUND_MEM_LIMIT--background-mem-limit / background_mem_limitShared scratch capacity for optional maintenance10% of work

The outer defaults keep min_free = max(1 GiB, TOTAL / 10) and MEMORY_LIMIT = TOTAL - min_free. Cache allowance, work and runtime headroom use 40%, 45% and 15% of MEMORY_LIMIT; foreground is work minus apply and background. TX/query caps sit within foreground, not on top of it. These are allowances rather than per-connection preallocations. The resolver rejects zero usable capacity and inconsistent partitions; successful configuration resolution alone is not proof that every supported entry fits the Apply/replay envelope. Unavailable discovery requires an explicit absolute own-heap ceiling and must remain reported as unknown, not invented host RAM.

On the server, PATINADB_CACHE_LIMIT=0 disables entry caches, but neither disables the work-memory governor nor sets native cache capacity to zero. An explicitly empty or whitespace-only value (for example, PATINADB_CACHE_LIMIT='') also keeps entry caches disabled, as before. The native cache allowance in these cases retains the automatic share of 40% of PATINADB_MEMORY_LIMIT.

Admission discovery follows the process’s actual controller membership and mount information. V2 checks visible ancestor limits; v1 uses the kernel’s hierarchical memory limit. Unreadable or ambiguous controller data prevents automatic sizing; a manual ceiling must still fit any bound that was observed. A subtree-only v2 mount requires an explicit budget because ancestors above it are unavailable. Namespace-hidden ancestors and changes during/after discovery remain limitations; this startup snapshot does not prove exclusive available RAM. The independent entry-cache free-floor sampler still manages eviction; it does not choose another startup allowance or override a refused admission budget.

The server connects its shared work governor to the existing Linux process-metrics tick (every two seconds), using the original startup discovery and budget. It rechecks visible memory limits. A known bound below the original startup bound stops new positive foreground and optional-background reservations, even if an explicitly smaller heap still fits. When startup had no known bound, the comparison uses the checked sum of own-heap and minimum-free allowances. Missing data does not mean zero; a bound observed despite other missing data still counts. Limits and the original comparison baseline are never raised by this monitor. Raising the cgroup limit again does not reopen admission: restore a valid configuration and restart. Existing allocation owners remain charged, and protected committed Apply/recovery is not cancelled or skipped.

The node logs the reduction and sets the unlabeled Prometheus gauge patinadb_memory_admission_stopped to 1 (initially 0). New positive client reservations receive the existing typed PATINADB_WORK_MEM_LIMIT resource error. This sampled monitor is not a hard response deadline, cannot immediately shrink existing objects and cannot guarantee protection against an OOM between samples. It runs on Linux; there is no cgroup monitor on other platforms.

The four new controls accept positive byte counts, binary units such as MiB and GiB, or auto. Bare numbers are bytes. The checked parser treats KB/MB/ GB aliases as binary too; prefer explicit KiB/MiB/GiB. Fractional bytes round down. Zero, negative values, overflow, malformed input and percentage syntax in these four settings are errors. There is no unlimited switch for these new budgets; existing settings retain their documented zero behavior. Precedence is explicit CLI flag, then ENV, then YAML, then automatic default. Startup and --check-config share one checked resolution, retaining discovery and any problems. The dry run prints resolved budget bytes, discovery and the read-only inventoried native plan; it installs no governor, reserves no leases and opens no native database. A valid plan does not prove complete path coverage.

For a 2 GiB effective ceiling, automatic arithmetic produces 1 GiB own-heap, 314,069,483 foreground bytes, 78,517,370 per transaction and 39,258,685 per query. The bounded control and seven default-smoke generations at 4bfb2d3e observe these settings without memory overrides (PR #1410; see the startup validation ledger). The older proposed 64 MiB / 32 MiB profile below is only an explicit-override example, not the current managed-default qualification profile. An 8 GiB ceiling reaches the automatic 256 MiB / 128 MiB TX/query ceilings; more RAM does not keep increasing those per-request caps. A ceiling of 1 GiB or less leaves no own-heap with the 1 GiB minimum free allowance and is refused; smaller-node support would need an explicit, separately qualified partition. The bounded 2 GiB observations and other arithmetic examples do not establish generally safe defaults. The protected apply budget must handle the supported committed-entry envelope before any default can ship, including after restart and on every replica.

Equivalent explicit overrides for the historical proposed example:

PATINADB_MAX_TX_MEMORY_BYTES=64MiB
PATINADB_MAX_QUERY_MEMORY_BYTES=32MiB
PATINADB_APPLY_MEM_LIMIT=auto
PATINADB_BACKGROUND_MEM_LIMIT=auto
max_tx_memory_bytes: 64MiB
max_query_memory_bytes: 32MiB
apply_mem_limit: auto
background_mem_limit: auto

Matching flags are --max-tx-memory-bytes 64MiB --max-query-memory-bytes 32MiB --apply-mem-limit auto --background-mem-limit auto. Current configuration/parser, precedence, automatic-default and read-only --check-config controls pass as recorded in the startup validation ledger. These source checks are separate from full pressure qualification. Rejecting a client transaction must preserve atomicity; a timeout after proposal still requires the existing outcome/readback rules. A replica cannot reject an already committed entry based on its local client budget. See limitations.

Cache memory budget

For an overview of how these regions nest, with calculated values for 2 GiB and 16 GiB, see How Storage Works → Memory model.

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 to disable the four decoded entry caches. On the server, native stores keep their inventoried aggregate allowance and work admission stays active; this is not an unrestricted-memory or zero-runtime-cost mode.

The server prepares the existing empty 4 MiB W-TinyLFU frequency sketch while constructing an enabled governor, before query admission. This moves initialization cost to construction; it records no synthetic access, warms no query result and adds no ENV/CLI/YAML setting or changed default. Disabled construction stays lazy. An embedded caller that later enables its disabled governor with set_budget retains the existing lazy behavior. Source checks pass. Its bounded performance comparison is recorded in the linked ledger; it does not establish a general latency bound or new-source pressure qualification.

The budget uses the shared cgroup-aware discovery described above: observed host RAM and all accessible controller bounds constrain the effective total. Missing or ambiguous hierarchy data cannot become a successful host-RAM fallback. This observed ceiling is not a guarantee against OOM or other consumers. 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. Value precedence is CLI > ENV > YAML > default, regardless of the value’s format. Fractions are evaluated against the resolved parent.

Env varRegionDefaultParent
PATINADB_MEMORY_LIMITown-heap ceiling (excl. page cache)auto = TOTAL − min_freediscovered TOTAL
PATINADB_CACHE_LIMITCombined entry/native cache allowance; 0 disables entries only, retaining the automatic native allowance40%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 setting or partition. Successful startup logs the resolved budget in bytes; --check-config prints those values when memory resolution succeeds.

These budgets are not a hard process-RSS limit. The enabled sampler caps host MemAvailable by each readable cgroup-v2 ancestor’s remaining allowance plus clean, inactive file cache. It uses each ancestor’s own usage, including sibling processes. Dirty/writeback and shared-memory pages are not credited as immediately reclaimable. Missing cgroup data retains the host fallback; missing all data does not invent memory pressure. This is a conservative, non-atomic estimate, not an allocation-admission guarantee. The existing cache free-floor feedback now uses this estimate, so abundant host RAM cannot hide pressure inside a container.

On Linux/glibc, the enabled sampler also requests release of already-freed allocator pages every 30 seconds (#1306). Below CACHE_MIN_FREE it may request this sooner, at most once per second and no faster than the sampler cadence. After a reclamation attempt the sampler reads availability again before the cache free-floor policy decides whether useful entries must be evicted. Those are maintenance policy intervals, not measured latency or RSS guarantees. Reclamation does not discard live data, invalidate indexes or change configured cache limits. Fragmented pages containing live allocations can remain resident. Other targets do not use this glibc-specific maintenance. Keep headroom and monitor actual process/cgroup memory. A smaller cache or periodic reclamation cannot guarantee that a concurrent workload will fit its hard memory limit.

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.

The core decoded caches share 4 * floor(cache_limit / 8) bytes. Normally objects, properties, adjacency and L3 results each receive one quarter of that allowance. When PATINADB_L3_MAX_RESULT_BYTES is below the fixed 256-byte L3 entry overhead (for example 1), L3 cannot admit entries and the other three core levels split its unused share equally. This environment setting is resolved once; restart after changing it. Zero or invalid values retain the 1 MiB default. L4 is disk-backed and has no decoded share to donate: setting PATINADB_L4_VICTIM_MAX_BYTES=0 still disables only that victim tier.

With otherwise default budgets, the calculated adjacency cap at a 2 GiB cgroup limit becomes 68.267 MiB with L3 disabled, versus 51.2 MiB with L3 enabled (#1442, static budget arithmetic). This is not a measured speedup or a guarantee that a working set fits. Native-cache requests, the free-memory floor, work budgets and auxiliary-cache caps are unchanged. The sampler can still evict under pressure. An 8 GiB cgroup with PATINADB_MEMORY_LIMIT=1GiB and PATINADB_CACHE_MIN_FREE=1GiB reproduces that application budget for diagnosis; it does not reproduce kernel reclaim under a 2 GiB hard limit.

These env-only tuning knobs sit next to the budget and are validated by --check-config:

Env varDefaultEffect
PATINADB_REDB_CACHE_LIMITInventoried aggregate sizing belowExact per-primary-file native cache request. Unset selects automatic sizing; 0 or empty requests redb’s built-in 1 GiB, never unlimited/disabled. Binary byte sizes and percentages of the same discovered TOTAL are accepted. Invalid, overflowing or non-Unicode values, percentages without reliable discovery and impossible aggregate requests refuse startup and --check-config; startup requests are never clamped. auto is not an override value: unset it instead.
PATINADB_PRIMARY_POINT_IOonLinux primary-property point reads use a separate no-read-ahead handle when allocated database size exceeds detected RAM at open. off retains native I/O for diagnosis. Standard boolean values apply; reopening is required. Ranges and writes retain the native handle.
PATINADB_CACHE_ADJACENCY_MAX_EDGES8192Per-list admission ceiling for the L2 adjacency cache, in edges (issue #808). A fully-drained incident-edge list longer than this is never cached — a supernode-scale single-touch scan keeps streaming off the raw storage range scan instead of flooding the cache and displacing small hot anchors. 0 disables the ceiling (not recommended: giant scans then churn the level). Performance-only; never affects results.

Disposable redb stores (historical reconstruction, resolve scratch, physical Raft checkpoint candidates and the L4 victim tier) use at most 64 MiB of native page cache per file, or the smaller configured live-file cache. With the exact live-file override set to 0, scratch requests remain capped at 64 MiB; disabling entry caches still uses the aggregate automatic native sizing described below. They do not silently inherit redb’s 1-GiB default. This is a per-store cache limit: concurrent stores, in-memory scratch backing, staging buffers, query results and the OS page cache still need headroom within a process/cgroup limit.

The RFC 0036 development API cache::native::install(CacheCapacity { total, entries, protected }) additionally reserves all native opens against total - entries. The four standard entry levels share entries; a share that rounds to zero keeps entry admission disabled, including after budget changes. Install before cache registration and with no live native handles. The split is immutable; it does not resize existing caches. protected keeps part of total - entries unavailable to temporary opens; it must fit that native remainder. Persistent graph/Raft/auxiliary stores can use the protected floor and otherwise free native capacity, always subject to the total. Ordinary temporary/scratch/checkpoint/ compaction opens share only the unprotected remainder and refuse if their requested cache does not fit. The explicit required-open distinction below handles the listed committed/recovery paths without changing ordinary optional admission. Native capacity remains charged through owned read guards, open retries and failed-open cleanup. The existing per-file override (including its zero/default request) cannot bypass this allowance. This API is selected by the server through the shared StartupMemory::prepare function, without another ENV/CLI/YAML switch. Startup plans known graph/Raft/audit owners before opening files and holds one required Fork/Squash reconstruction slot: it uses the existing scratch size, retains the same charge through final native-owner release, and adds no ENV setting. Native/Sim bounded ownership/progress controls pass; this is not complete runtime qualification. The explicit sizing API below covers known startup owners and Main now selects it; complete Apply/recovery memory qualification remains open. Configured capacity is not RSS: native/pinned/teardown overhead and in-memory backing remain outside this cache counter.

The server calls StartupStorePlan::prepare_sized through StartupMemory::prepare. It derives fixed per-file requests from the same resolved aggregate cache allowance C and one physical startup inventory. The matching entry-cache governor is installed before native policy and before any entry cache/native open. The plan consumes that inventory once and transfers the original reservations to registry, Raft, audit and required reconstruction owners; opening a file does not reserve twice. --check-config resolves the same budget, strict REDB override and inventoried formula read-only; it installs no policy, reserves no slots and opens no native database. Neither API is a promise that all future databases or overlapping recovery operations fit.

The explicit plan keeps E = 4 * floor(C / 8) for the four entry caches, or E = 0 when they are disabled. D includes every actual graph file plus missing default/system; Raft trees share one file and audit has one separate file. With N = C - E and s = min(p, 64 MiB), the revised automatic plan chooses the largest positive whole-byte p such that (D + 1) * p + (2D + 8) * s <= N. Required startup owners reserve (D + 1) * p + 2 * s, including audit and the held required-reconstruction owner. The remaining role allowance accounts for D retained tag-export bases, D Raft snapshot mirrors, one sequential checkpoint, two scratch owners for each of the two shared periodic/tag build slots, and one additional foreground scratch owner. Periodic and tag work share those two slots; they are not separate concurrency allowances. Existing builds can overlap a Raft snapshot because its inhibitor prevents new starts without draining them.

This divides the unchanged aggregate native allowance into smaller automatic per-file requests. It neither reserves these optional roles against other queries nor guarantees capacity for arbitrary foreground concurrency, historical-query caches, newly created databases or retained old generations. One foreground scratch owner is not a whole-transaction concurrency guarantee. Native active pages, in-memory backing and other allocations remain outside this configured cache capacity; it is not an RSS limit. The revised role calculation passes source and real-owner integration checks after a second pre-mix smoke refusal. The subsequent bounded automatic-default control and original smoke at 4bfb2d3e pass, followed by the agreed full pressure qualification after final review. This does not cover arbitrary native-owner concurrency or establish a universal capacity bound. Performance acceptance remains open; the completed write and retained-tail comparisons preserve their separate latency findings.

The explicit API takes an already-parsed exact REDB override: None is automatic, Some(bytes) is never clamped, and Some(0) still requests redb’s native 1 GiB cache. The exact primary request and its bounded scratch requests must fit the complete role plan above or startup and --check-config reject; no override is silently reduced. When entry caches are enabled, a matching entry governor must be installed first; live native owners or registered entry caches reject later policy installation. The fixed policy is retained after later startup I/O failure while leases follow actual owner lifetime. Startup must terminate after such a failure; retrying a different configuration in the same process cannot replace the once-only policy. The agreed default-enabled pressure qualification is complete; arbitrary required-memory capacity and performance acceptance remain outside that result. See the sizing contract and source validation.

The required-native increment, with focused source validation, gives explicitly required post-startup persistent opens the smaller of their request and currently available native cache allowance, including zero. It covers committed Create/Anamnesis/Fork targets, received snapshot probes/replacements and legacy record restore. Pending or forced install repair uses the smaller of request, native remainder and temporary remainder for its mirrors/checkpoint before clearing the durable intent. Ordinary opens and periodic snapshots retain strict admission; required temporary work cannot consume the protected persistent floor. Leases track granted bytes and retain their real native-owner lifetime, including old/new overlap and zero byte owners. Reduced grants warn; real storage errors still fail.

Databases created after startup (CREATE DATABASE, fork targets, Anamnesis companions, record-restore creates) receive native cache only beyond the startup plan’s temporary inventory — the capacity kept for snapshot mirrors, checkpoints, export bases and the foreground mirror (#1499). Automatic sizing gives all other capacity to the files inventoried at startup, so with an unset PATINADB_REDB_CACHE_LIMIT a newly created database runs with a zero-byte configured cache until the next restart re-plans across all graph files; capacity returned by a dropped database is reused. An explicit override smaller than the automatic size leaves the difference for later databases. Earlier builds applied a protocol-17 CREATE DATABASE with strict admission: where the free share was smaller than one planned request (every large host under automatic sizing) the committed entry was a fatal apply error and the node aborted, also on each replay. The startup-planned server path is covered by an accounting test, not an RSS or performance measurement; the cost of zero configured cache is unmeasured.

An internal granted zero is not PATINADB_REDB_CACHE_LIMIT=0: that ENV value still requests 1 GiB, and strict startup must fit the exact request. Zero configured cache does not eliminate active/pinned/write pages, native metadata or in-memory mirror backing. The required-open path itself adds no switch or durability/deadline change; server activation is described above. See the required-open and repair scope.

The separate development Limits::memory_governor integration also admits /raft/append wire buffers from the shared protected Apply pool. It preserves PATINADB_RAFT_MAX_BODY_BYTES, including on chunked bodies. A retryable peer 503 with code=PeerAppendMemory means the wire buffer or decoded operation payload was refused before Raft handoff; it does not promise the entry had no effect elsewhere. Decoded operation arrays and recursive values retain their Apply credits through the final OperationBatch owner. The server uses this same governor, without a separate transport memory toggle. Full default activation qualification remains pending. The outer entry array, metadata/CAS fields, interned identifiers, parser/native transport scratch, vote ingress and complete committed apply/replay still need coverage and capacity qualification. Stored entries are not yet proven to fit every configured Apply pool; transport retries alone do not provide that progress guarantee.

Retained-tail boot recovery streams current-format entries after full preflight without a new setting. It preserves group durability and keeps a private temporary hash file beside the retained tail; scratch creation/write errors prevent restore. There is no supported memory cap for individual stored entries from this change. Live forced installs also save a pinned native log range one entry at a time, then reuse that replay path, without an additional switch. Missing/corrupt source logs still require reseeding; retained-copy write errors precede graph restore. Legacy v1 boot recovery uses an anonymous copied tail and the same verifier. A bounded runtime Apply diagnostic confirms another open protected-path gap: OpenRaft’s replication entry cap does not bound full committed-range decoding or multiple decoded batches queued behind a held consumer. After release all checked values apply correctly; this is a memory-scheduling gap, not observed data loss. The diagnosis alone did not qualify a scheduler fix or default activation. See Apply queue diagnosis.

The bounded Apply integration now selects the reviewed OpenRaft source. With the explicit development governor, the serial worker loads at most 64 whole entries and respects the existing encoded-byte reader target; an oversized first stored entry still progresses intact. Pending ranges coalesce without crossing snapshot commands, and source logs survive until durable Apply/install permits purge. These are scheduling bounds, not an individual entry’s decoded-memory or native-page bound. Focused integration and Raft/Sim/crash controls pass. The subsequent Bolt boundary correction resolves the earlier fixture failures with fixed test allowances and additional small-cap refusal controls. Complete resource qualification, defaults and the new pressure soak remain open. No ENV toggle is added by this integration.

Raft log append now serializes entries one at a time inside the same atomic native write transaction. Each encoded buffer is released before generating the next; success is acknowledged only after the complete durable commit. Generation or storage failure cannot publish a partial generated batch. This removes the whole-batch encoded staging buffer; input entries, individual encoded values, native transaction pages and complete protected Apply ownership remain separate obligations. Those log-stream prerequisites alone enable no default. Runtime cost still needs measurement. See atomic append validation.

With the explicit development governor, ordinary committed boot replay also streams individual entries before Raft construction. Its original 64-entry batch boundaries and durable barriers stay intact. Each batch is decoded once for preflight and again for apply, so startup cost still needs measurement. Corrupt or missing committed records prevent readiness. The fault-injection build keeps its existing asynchronous recovery seam. Non-Apply complete log readers, per-entry expansion and complete protected/native capacity still need qualification; the streaming-replay prerequisite alone does not qualify default activation.

Default asynchronous snapshot commits attempt retention pruning without waiting for historical readers. A busy lease leaves all snapshots intact and retries on the next snapshot-taking commit; the retention count remains a soft cap. Explicit synchronous snapshot mode retains its bounded wait. Read consistency and snapshot publication/durability rules do not change.

The primary-point policy uses the host/cgroup RAM ceiling, including the process’s nested cgroup-v2 scope and tighter visible ancestor limits. It is a coarse eligibility check, not a query working-set or RSS bound. New empty stores and stores that fit that ceiling keep native I/O until reopen re-evaluates them. EXPLAIN shows the policy; PROFILE reports query-thread backend requests, not physical disk traffic. Unavailable Linux file advice falls back to native I/O.

Internal defaults (informational)

These are mostly not user-configurable flags today, but are useful to know:

SettingValue
Engram (history) snapshot interval, per databaseevery 50 commits, or earlier once 1 MiB of deltas accumulated (PATINADB_SNAPSHOT_DELTA_BYTES; 0 = count only)
Raft snapshot intervalevery 5,000 log entries (PATINADB_SNAPSHOT_LOGS_SINCE_LAST; 0 or invalid = default)
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 directory and table map is in How Storage Works → Where your data lives. 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.

Entry and join cost model

PATINADB_COST_MODEL accepts exactly off, entry or join (case-sensitive), once per process. Since 2026-10-17 unset or empty means join (#1442). off restores the reference planner; any other value also selects off, as before the default change, and --check-config rejects it. entry compares whole-path costs for labelled entries in fixed-length paths without a sorting hint. UUID, sorting, shortest and variable-length policies remain unchanged. EXPLAIN/PROFILE record entry.cost alternatives in estimated nanoseconds, output rows, provenance, the dominant term and assumptions. entry.cost.hops describes estimated scanned and accepted rows for each hop. entry.cost.consumer adds work a consumer repeats per run of one binding; today only the count over independent OPTIONAL one-hop branches is priced. These values are experimental estimates, not latency predictions (#1442). The constants use A2-cal primitive measurements; full query estimates still assume independent filters. Warm redb is the default; warm L2 is estimated only for lists within the admission limit and a working set within the enabled level cap. Cold is never selected automatically. Secondary checks use inline costs only with A1 observed compact-payload coverage. MANY and NO_INLINE observations retain membership cost, and the current view must still be READY. These are cost assumptions, not cache-hit guarantees.

join includes entry and additionally estimates fixed-pattern join outputs from population-conditioned degrees. Hash build/probe and seeded Apply traversal compete in estimated nanoseconds. Small join chains use subset DP, medium chains use interval DP over a greedy ordering, and larger chains use greedy search. The result replaces the reference join plan only when the same model predicts an advantage of more than 1.1x (all inputs exact) or 2x (sampled, default or stale inputs). A join chain directly under count(*) whose reference plan is rewritten into grouped per-side counts keeps that plan if the result would lose the rewrite; EXPLAIN reports kept reference: it executes as a partial COUNT. Chains without any relationship, chains with a cycle-closing pattern (a node variable repeated inside one pattern), unsupported paths and refused planning scratch retain the reference join planner. The margins are unverified policy choices, not measured optima (#1442).

join.order and join.strategy show the selected plan’s alternatives and provenance. Cached join plans retain their original decisions and schema epoch; data/statistics refreshes do not replan them. PROFILE marks unavailable operator counts explicitly. Q-errors describe estimates, not correctness checks.

With entry or join, the model also replaces the PATINADB_PAR_AGG_MIN_ROWS floor for parallel admission (parallel.admission reports serial and parallel estimates) and enables the auto values of PATINADB_WCOJ and PATINADB_CSR. Without a cost model, auto behaves as off. These switches change only plan and operator choice, never results (#1442).

PATINADB_DEGREE_STATS_EDGE_BUDGET is an unsigned edge budget, read once per process (default 1000000; 0 disables background refresh). With entry or join (the default), missing or stale A1 groups request a single background sample, so the model works without a manual CALL patinadb.stats.degrees(). A group missing from an unsampled catalog requests nothing while its edge type and both labels still have current rows, because that catalog scanned every edge and the pattern direction has none (#1442). The requesting query uses cached legacy fanout or a default immediately. It never waits for this refresh. The worker has a cooperative 120-second deadline and observes global shutdown; its construction memory and published catalog use the governed stats.degree region. The worker reserves what its group ceiling needs (about 13 MiB for the 4,096-group maximum), or what is left of the region if that is less, but at least enough for 256 groups. The granted bytes then cap the groups and the encoded catalog, so small nodes (2 GiB) still get a sample. A catalog that would exceed the grant is not published (#1442). Refusal preserves the fallback. Population counts still visit vertices; the edge budget alone is not a vertex-work bound. There is no new background work when the model is off. Samples assume uniform UUIDs. A conservative confidence margin retains the reference entry for weak legacy/stale advantages or sampled estimates whose apparent advantage is below their cardinality disagreement.

What the background sample means in practice:

  • The first queries of a shape after start, after a database is created and after writes that make a group stale plan with the fallback (reference or legacy-fanout estimates). Plans can change once the sample is published; results never do.
  • The sample is node-local RAM. It is not persisted or replicated and is rebuilt after a restart. Snapshot and overlay reads never start one. One worker runs per process, with retries for the same database spaced at least 5 seconds apart.
  • The work is not free: up to the edge budget of sampled relationships plus one pass over all vertices for the population counts, on one background thread. A disabled entry cache (embedded use without PATINADB_CACHE_LIMIT), or one too small for the 16 MiB construction reservation, refuses it; the model then works on default estimates with its widest (2×) adoption margin.
  • Join orders (join) are part of the cached plan: one planned before the sample arrived stays until a schema change or eviction. Entry choices are re-priced when the catalog changes.
  • CALL patinadb.stats.degrees() still refreshes exactly (or with an explicit edge budget) on demand, for example as a warm-up after bulk loads.

Restoring the reference behaviour

The four RFC 0042 switches are on by default since 2026-10-17 (#1442): the measured candidate gave Velr 0.544, LSQB 0.878 and SF1 0.995 of all-off (geometric means of median ratios, three server rounds; RFC 0042 A6). Set all four to off to restore the previous planner and executor exactly:

PATINADB_COST_MODEL=off PATINADB_FACTORIZE=off PATINADB_WCOJ=off PATINADB_CSR=off

Each switch can also be turned off alone. PATINADB_COST_MODEL=off alone leaves PATINADB_FACTORIZE on (every proven shape then engages, uncosted) and makes the auto modes of the other two inert. Every switch is read once per process; restart after a change. Explicitly set values keep the meaning they had before the default change; only unset or empty values changed. --check-config prints the effective mode of each switch, marking defaults. PATINADB_QUERY_THREADS stays 1.

Pipelined write snapshot admission

Before executing a pipelined Cypher write, Bolt and REST may wait for a complete native read snapshot within the existing query deadline. Only snapshot acquisition is repeated: this does not replay the query, a WASM function, or a submitted mutation. Other storage errors propagate. If the query timeout is disabled, or the calling thread already owns a deferred native write, an unavailable publication is refused rather than waited on. The network server’s existing default query timeout remains 300 seconds; no new setting is added. The focused publication-race regression is verified; renewed full pressure qualification of this change is still pending.

How Storage Works

This chapter is the structured reference for how patinaDB stores a graph, reads it back, caches it and budgets memory for it. It is written for operators and developers who size, tune and troubleshoot a deployment. After reading it you should know where your data lives, what a hop and a property read cost, what the caches hold, how memory is divided, and which settings matter.

Other chapters keep the details and are linked where they go further: Caching & Memory Tuning and Cache Observability & Tuning for the caches, Configuration → Cache memory budget for every memory setting, Data Model for the logical model, Day-2 Operations for disk reclamation and upgrades, and Limitations for the open edges.

Three rules hold throughout:

  • The redb file is the only source of truth. Every cache, statistic and derived structure described here is a copy that can be dropped at any time. A cache hit can make a read faster; it never changes a result. A cached entry whose generation stamp does not match the reader’s snapshot is a miss, not a stale answer.
  • Everything in RAM is node-local. Caches, statistics, the degree catalog and CSR partitions are never written to the Raft log, never replicated and never sent to another node. Each node builds its own from its own copy of the data. Only L4, the optional result spill file, lives on local disk.
  • Numbers in this chapter carry their source. Defaults and limits are quoted from the source code. Every measured number names the measurement, its conditions and its issue (#1442 unless stated otherwise). Measurements come from one shared development host and are indicative, not a promise for your workload. Values marked calculated are arithmetic from the source formulas, not observations.

The picture at a glance

LayerWhat it isWhereSurvives a restart
Graph fileOne redb B-tree file per database: vertices, labels, adjacency, properties, indexes, search, history, metadata<data>/<db>/patinadb.redbyes
History bodiesEngram snapshot bodies<data>/<db>/_engram_snapshots/yes
Raft stateReplicated log, vote, state-machine metadata, users, IAM<data>/_raft/patinadb.redbyes
AuditNode audit log<data>/_audit/patinadb.redbyes
Native page cachesredb’s own page cache per open fileRAM, inside the cache poolno
Decoded cachesL1 objects and properties, L2 adjacency, L3 results, auxiliary cachesRAM, inside the cache poolno
Derived structuresMembership sets, CSR partitions, planner statistics, degree catalogRAMno
L4 victim tierSpilled expensive L3 results (off by default)<data>/<db>/_l4_victim/yes (revalidated on use)
OS page cacheKernel cache of file pagesRAM outside patinaDB’s heapnot reliably

Figure 1. What one node stores on disk. Table names are those in the source. The red strip lists what lives only in RAM.

Where your data lives

Files and directories

The server’s --db directory is the registry root. Each database is a subdirectory named after the database, holding one redb file named patinadb.redb. default and system are reserved database names. Directory names that start with _ are reserved for internal state and are never opened as databases:

PathContents
<db>/patinadb.redbthe whole graph of one database, with its indexes and history tables
<db>/_engram_snapshots/Engram snapshot bodies ({id}.snap, {id}.snapidx sidecars); staging files left by a crash are removed at open
<db>/_l4_victim/the optional L4 result spill file (only when L4 is enabled)
_raft/patinadb.redbRaft log (raft_logs, keyed by log index), vote and state (raft_meta, raft_sm), users and IAM
_audit/patinadb.redbthe audit log (raft_audit, raft_audit_meta), in its own file so audit I/O does not wait on the Raft writer
_raft_snapshots/Raft snapshot files
_physical_install/, _snapshot_install_intent/, _maintenance/, _diff_jobs/in-progress install, maintenance-job and diff-job state

Full-text and vector indexes have no files of their own: they are tables inside the database file. Back up the whole --db directory as a unit, or use the portable backup.

Tables inside a database file

Every table is an ordered byte-key B-tree. Keys sort bytewise, so the layout of a key decides which scans are cheap: anything that shares a key prefix is stored together and read with one seek and a range scan. A UUID inside a key is 17 bytes (a 0x10 tag plus the 16 raw bytes), so UUID order is key order.

GroupTableKey → value
Vertices__patinadb_default__vertex UUID → primary label id
vertex_labelsprimary label id + UUID → empty (the label index)
Secondary labelsvertex_extra_labelsUUID → secondary label ids
vertex_extra_labels_by_labellabel id + UUID → primary label id (the inverse index; trusted only while its READY marker is present)
vertex_inline_exemptUUID → two bits: in-degree or out-degree above 4,096
Adjacencyedge_ranges, reversed_edge_ranges42-byte edge key → inline secondary labels (next section)
Propertiesvertex_propertiesUUID + property name id → value frame (≤ 256 bytes inline)
vertex_property_payloadsproperty name id + UUID → values larger than 256 bytes
edge_propertiesedge + property name → value
Indexesvertex_property_valuesname id + label id + encoded value + UUID → empty
compound_index_defs, compound_index_entriesdefinitions; label and fields + encoded values + UUID → empty
edge_property_valuesname + value + edge → empty
edge_sorted_index_defs, edge_sorted_index_entriesdefinitions; index + anchor + encoded target property + target → empty
Searchfulltext_catalog, fulltext_indexindex definitions; postings, document lengths and corpus statistics
vector_catalog, vector_postings, vector_assignindex definitions; vectors per IVF centroid; centroid per vertex
Historyengrams, engram_deltas, head, snapshotsEngram metadata, per-commit delta lists (optionally zstd-framed), current HEAD, snapshot markers
Metadatametadataschema version, the label/type/property-name registry, durable generation counters, READY markers, index and constraint definitions
idempotencyclient idempotency tokens
Cache validation__patina_direct_result_revisionrevision → little-endian u64, advanced atomically by direct non-deferred mutations; read by L3/L4 against the query snapshot

Index values are empty: the key is the entry, and an index lookup is a seek plus a range scan over keys. Labels, relationship types and property names are interned once in an append-only registry and stored as 4-byte ids; the registry holds at most PATINADB_MAX_LABELS names (default 1,000,000, see Day-2 Operations).

Adjacency keys and inline labels

Each relationship is stored twice: once in edge_ranges under its source and once in reversed_edge_ranges under its target. The 42-byte key is

source UUID (17) | relationship type id (4) | target primary label id (4) | target UUID (17)

and the reverse tree swaps the endpoints, so in both trees the label slot holds the primary label of the vertex the hop lands on. Consequences:

  • Prefix scans. All relationships of one vertex share a 17-byte prefix, all of one type a 21-byte prefix, and all of one type whose target has a given primary label a 25-byte prefix. A typed hop is one seek plus one range scan. Within a 21-byte prefix, entries sort by target primary label, then target UUID.
  • No relationship id. A relationship is identified by (source, type, target). There is no separate id and no second relationship of the same type between the same two vertices (see Data Model → Parallel edges).
  • Primary-label checks are free. The target’s primary label is in the key, so (a)-[:R]->(b:Person) with Person as b’s primary label needs no read of b.

The value of an adjacency entry carries the landing vertex’s secondary labels (schema 8, RFC 0040 increment 1):

  • an empty value means the vertex has no secondary labels;
  • otherwise the value is the vertex’s secondary label ids, sorted;
  • a single byte 0x00 (NO_INLINE) means “not stored here, look it up”.

A vertex whose in-degree or out-degree, over all relationship types together, exceeds 4,096 is marked in vertex_inline_exempt. From then on, entries that point at it in that direction carry NO_INLINE instead of its labels. The mark is sticky: it stays when the degree drops again, and is removed only when the vertex is deleted. It exists because a label change on a vertex rewrites every non-exempt adjacency entry that describes it, in the same write transaction. Without the bound, relabelling a hub would rewrite all of its entries.

Readers trust inline values only while the database’s inline-labels READY marker is present. A store without READY (an upgraded schema-7 store before its rebuild, or an interrupted rebuild) falls back to the lookup path for every secondary-label check, so results never depend on the marker. See Day-2 Operations → Inline secondary labels for the open-time rebuild and PATINADB_INLINE_LABELS.

Vertex properties: inline and external

vertex_properties stores one entry per (vertex, property). A serialized value of at most 256 bytes is stored inline in the entry. A larger value is written once to vertex_property_payloads, and the primary entry keeps a one-byte marker. The payload table is keyed property-first, then owner. A missing payload is reported as corruption, never as NULL. Edge properties are always inline. The 256-byte threshold is a fixed heuristic (#1399), not a measured optimum; see Query Planning → Inline and external vertex properties.

What is not on disk

Planner statistics (label counts, column statistics, fan-out) and the RFC 0042 A1 degree catalog are computed on demand and kept only in RAM. They are capped at 4,096 entries or 4 MiB, never written to the metadata tree, never replicated and lost on restart (see How the Query Planner Works → Statistics). The same holds for every cache level except L4 and for CSR partitions. After a restart a node is correct immediately and fast again once its caches and statistics have been rebuilt by use.

Format versions

VersionValueMeaning
SCHEMA_VERSION (database file)85: postcard codec with zstd frames; 6: 42-byte edge keys; 7: inline/external vertex properties; 8: inline secondary labels and vertex_inline_exempt
SNAPSHOT_FORMAT_VERSION (Raft snapshot)8format 8 carries schema-8 physical bodies
PROTOCOL_VERSION (peer protocol)22every member must report 22 before physical snapshots are used

Opening a schema-7 store migrates it in place to 8 and then rebuilds the inline values. Schema 5 is refused with a re-import error; other older versions fail with an “export and import” error; a store written by a newer binary is refused. See Limitations → Storage & scale.

What one hop costs

A hop such as (a)-[:R]->(b:Label {p: …}) runs in three stages for every anchor a. Each stage tries its sources in order and stops at the first that can answer.

Figure 2. The read path of one hop. The times are the A2-cal v1 primitives in the table below (#1442).

  1. Neighbours of a.
    • A CSR partition, when one is built for this relationship type and direction, its snapshot matches the reader’s and a has not been written since the build. Under the shipped PATINADB_CSR=auto only deduplicating variable-length traversals use it; see CSR adjacency.
    • Else the L2 adjacency cache: live reads of a single relationship type, for lists of at most 8,192 entries, validated by the anchor’s label generation and the relationship type’s generation.
    • Else a redb range scan: seek to the 21-byte (a, type) prefix (17 bytes for an untyped or multi-type hop, then a type filter) and read the entries. A fully read list that fits the L2 limit is offered to L2.
  2. Labels of each b.
    • A primary label is compared against the key’s label id.
    • A secondary label is answered from the inline value when the store is READY and b has no secondary label or exactly one.
    • Otherwise (two or more secondary labels, NO_INLINE, or no READY marker) the membership cache answers: a per-label set of holders, built after 65,536 checks of one label within one generation, else a point read of vertex_extra_labels.
  3. Properties of b, only those the query reads: the L1 property cache (which also caches confirmed absence), else a point read of vertex_properties, plus a second read of vertex_property_payloads for a value larger than 256 bytes. Relationship properties use the separate l1.edge_properties cache in the same way.

Historical (AS OF) reads and the write-resolve mirror bypass the live caches and read the tables directly. Parallel workers (PATINADB_QUERY_THREADS above

  1. use a per-query shared cache instead of the process-wide L2.

Primitive costs (RFC 0042 A.5)

The cost model is calibrated against these primitives. They come from the A2-cal v1 calibration (#1442) and are the V1 constants in crates/patinadb/src/plan/cost/constants.rs:

Primitivens per operationScope of the measurement
next entry, typed L2 list4.3payload iteration only; excludes the cache lookup
inline secondary-label accept8.9one InlineTargetCheck accepting a secondary label
next entry, warm redb range24.8raw cursor step on the SF1 store; excludes decoding
L1 property hit43.1resident integer property; excludes any redb read
secondary-label membership hit154.3resolved membership check
seek to a key prefix, warm redb294.5cursor positioning only
next entry, advisory-cold redb879.7first 65,536 entries after POSIX_FADV_DONTNEED; a sequential read-ahead regime

Figure 3. Hop primitives, A2-cal v1: chris-aibox (Ryzen 9 9950X3D2), cores 0–3, 2026-10-16, median of seven batch minima; seek and redb steps on a reflink copy of the LDBC SF1 store, the rest on generated fixtures (RFC 0042 A.5, #1442).

How to read them:

  • Each value is one primitive’s own scope, not a hop latency. A real hop adds decoding, row construction, filters and cache bookkeeping. Do not add them up and quote the sum as a measured hop cost; such a sum is unverified.
  • The ratios are what matters for design. An L2 entry is about a sixth of a warm redb entry, a seek costs as much as about twelve warm redb entries, an inline label check is about a seventeenth of a membership hit, and a cold entry is about 35 times a warm one (ratios of the table values, #1442).
  • Cold is advisory. The cold figure is a page-cache-evicted sequential read on one machine. The planner never assumes it by itself; it prices warm L2 or warm redb.

What RFC 0040 changed

RFC 0040 (“hop-local storage layout”, #1442) attacked the per-hop cost in two steps:

  • Increment 0, no format change. Hops take the target’s primary label from the key instead of reading the vertex; membership checks use a set held per read view instead of a global lock; cache bookkeeping got cheaper; absent properties are cached. On the synthetic ldbc_pins graph, vertex label reads fell from 5,383 to 1,427 for IC14 and from 320 to 21 for IC9. Paired SF1 runs measured IC3 at 0.62 and IC9 at 0.69–0.72 of the previous time (RFC 0040 “Increment 0 results”, #1442).
  • Increment 1, schema 8. Secondary labels moved inline into the adjacency value, with the 4,096-degree exemption. On a compacted SF1 copy, allocated file size grew by 0.68% (10,114,277,376 to 10,183,208,960 bytes) and the first open with the rebuild took about 95 s. Paired SF1 reads were 10–17% faster on IC4, IC6, IC8, IC11 and IC12, with no significant regression. Costs: about 14–15% on a deferred in-memory edge-insert microbenchmark (no regression in committed workloads) and about 8× for a label change on a vertex with 16 incident entries (RFC 0040 “Increment 1 measurements”, #1442).

Rejected in the same RFC: per-vertex adjacency blocks (a native redb comparison measured a 1.4–2.2× insert penalty, ADR 0017, #1442) and fixed-width key tables (not adopted; their savings were never measured).

Caches

Figure 4. How a server node divides its RAM, with the automatic defaults. The values are calculated from the source formulas for a 2 GiB and a 16 GiB ceiling, not observed.

What each cache holds

CacheHoldsKeySize boundGoverned
Native redb page cachefile pages of each open redb filepageper-file request planned at startup from C − Eyes, startup plan
l1.objectsa vertex’s decoded primary label(db, UUID)E / 4yes
l1.propertiesdecoded vertex property values, including confirmed absence(db, UUID, property)E / 4yes
l2.adjacencythe raw entries of one anchor’s adjacency list, per side and type(db, anchor)E / 4; lists over 8,192 entries never admittedyes
l3.resultsmaterialized results of repeated pure-read queries (seen at least twice, at most 1 MiB each)kind, db, schema epoch, exact text, parametersE / 4yes
l1.edge_propertiesdecoded relationship property values(db, edge, property)C / 8 quantumyes
l1.label_membershipholder set of one secondary label(db, label)C / 8 quantumyes
derived.csrCSR partitions, see CSR adjacency(db, type, direction)E / 9 once reservedyes
stats.degreethe A1 degree catalogper databasemin(C/64, 4 MiB) blob within a C/32 regionyes
shared worker cacheadjacency and properties for one parallel queryper queryC / 8 quantumper query
L4 victim tierexpensive evicted L3 results, on diskdb-free key and stampPATINADB_L4_VICTIM_MAX_BYTES per database (default 0 = off)own disk cap
Plan cacherewritten operator treesdb + normalized text512 plans, LRUno
Parse cacheparsed queriestext1,024 entriesno
planner.entries (A2 entry decisions)completed cost-model entry choicespattern + parameter values64 entries and min(Q/16, 4 MiB) estimated bytesyes

C is the cache pool (PATINADB_CACHE_LIMIT) and E = 4 × ⌊C/8⌋ the decoded entry allowance (next section). Q is the auxiliary cache quantum after native/CSR adjustments; absent those adjustments, Q = floor(C/8). A2 reserves its estimated key bytes before copying and keeps evicted-but-pinned entries charged until their final reader drops them. The plan and parse caches remain count-bounded and outside the governor; see How the Query Planner Works.

Not every level is in use for every query: L1/L2 only serve live reads, L3 only caches result shapes it can prove deterministic (see Caching → What gets cached), and the membership set is built only after 65,536 checks of one label.

Budget shares

With the shipped defaults the cache pool C is 40% of the own-heap ceiling PATINADB_MEMORY_LIMIT (Figure 4). It splits as follows (source: cache/level_budget.rs, cache/native/startup_plan.rs, cache/mod.rs):

  • Native and decoded halves. E = 4 × ⌊C/8⌋ goes to the four core decoded levels; the remainder C − E, about half, goes to redb’s native page caches. Startup divides the native half into fixed per-file requests for every graph file, the Raft file, the audit file and a set of scratch slots of at most 64 MiB each.
  • Four core levels. L1 objects, L1 properties, L2 adjacency and L3 results get E/4 each.
  • L3 unusable. If PATINADB_L3_MAX_RESULT_BYTES is below L3’s fixed 256-byte entry overhead (for example 1), L3 can admit nothing and the other three levels get E/3 each (RFC 0042 B.4).
  • CSR share. Once the CSR share is reserved, each core level keeps 8/9 of its cap (2/9 of E) and the CSR gets 1/9 of E. With PATINADB_CSR=on the share is reserved at start-up; with the shipped auto only after an admitted query has used a CSR. The auto rule exists because an unused reserved share made SF1 IC6 1.9× slower (#1442, cache/csr.rs).
  • Auxiliary caches (membership, edge properties, the shared worker cache) each self-bound at the older C/8 quantum (× 8/9 with the CSR share). These caps are not carved out of E, so the sum of all caps can exceed C.

The decoded caps count accounted bytes (entry payload plus a fixed per-entry overhead of 160–256 bytes), not allocator bytes. They are not an exact RSS bound.

PATINADB_CACHE_LIMIT=0 (also off, disabled or an empty value) turns off the decoded caches and A2 entry-decision reuse. The native page caches keep the automatic 40% share, the work-memory governor stays active, and the parse and plan caches keep working. Cache-off is therefore not a zero-memory mode.

Admission and eviction

  • Admission is W-TinyLFU with a 4 MiB frequency sketch: an entry is admitted on its second touch, and when a level is full a candidate must be estimated at least as hot as the entry it would displace. A one-off scan therefore streams through without evicting the hot set. A scan repeated in a cycle does pass the doorkeeper; that is the thrash case below.
  • Eviction samples eight resident entries and evicts the least recently used of them, repeated until enough bytes are free. A database holding more than its equal share is preferred as the victim source.
  • Governor tick. Once a second the server’s governor samples available memory. Below the page-cache floor it evicts the deficit, with hysteresis; above C it evicts the excess.

Invalidation by generations

No write updates a cache entry. Writes advance durable generation counters in the metadata table inside the same transaction, and a reader validates every entry against the generations it reads from its own snapshot:

CacheValidated against
L1 objectsthe label’s structural generation
L1 propertiesmax(structural generation, the (label, property) generation)
L2 adjacencyanchor label generation, relationship-type generation and the inline READY epoch; written anchors are also dropped eagerly
L3, L4every label, structural, property and relationship-type scope the plan reads, plus the schema epoch in the key
Membershipthe label’s structural generation at build time
CSRbuild snapshot, type generation, READY epoch, per-anchor dirty marks
Plan cacheDDL and graph clears; join-model plans also check accepted degree-catalog publications for their database (plain data writes retain plans)

A generation advances once per dirty label or relationship type when a transaction synchronizes, not once per row. One consequence: overwriting a property invalidates that property’s L1 entries for the whole label, not just the written vertex. Snapshot install, restore and clear_graph reset the generations, drop every RAM level for the database, truncate L4 and clear the plan cache. Older pinned readers keep seeing entries that match their snapshot.

Direct non-deferred physical writes can precede the logical generation bump. L1, L2 and edge-property entries additionally match a per-open certificate from the actual native read snapshot. A direct commit changes that certificate for the whole physical database; raced pins bypass those caches. Deferred atomic writes retain the dependency policy above. L3/L4 use a persisted revision in __patina_direct_result_revision, copied with native checkpoints and retained on reopen. Empty repair commits do not advance it. Missing legacy certificates, failed reads and an exhausted revision bypass result reuse. The revision read is memoized per native query snapshot; its runtime impact is not yet qualified. See limitations.

CSR adjacency

The CSR (compressed sparse row) cache is a derived, read-only, in-memory copy of one relationship type’s adjacency in one direction (RFC 0042 part D). It is never written to disk or replicated.

  • Layout. UUID-sorted anchor ids, per-anchor offsets and dense target numbers, with side arrays for the target’s primary label and inline secondary labels (NO_INLINE is kept).
  • When it builds. On demand, in the background, from one ordered pass over the direction’s adjacency tree on a pinned snapshot. The requesting query does not wait; it uses the next source until the partition is published. A failed build backs off for one second before the same partition is retried.
  • When it is used. Only if the reader’s snapshot is at least the build snapshot, the relationship-type generation and READY epoch match, and the anchor is not marked dirty. A partition more than a quarter dirty is not used and gets rebuilt. The quarter and the one-second backoff are unverified heuristics.
  • Write fences. Every adjacency write marks the touched anchors dirty inside the same scope that invalidates L2, and deferred writes keep that fence until the actual backend flush. A reader that cannot prove the order of its pin relative to a write declines the CSR.
  • auto (shipped default) vs on.
    • auto admits only deduplicating variable-length traversals (BFS where count(DISTINCT …) makes path multiplicity irrelevant), by cost, and only with a cost model enabled. Shortest paths and fixed hops stay on L2/redb. The share is reserved only after the first admitted use.
    • on also serves shortest paths and single-type fixed hops (D2) and reserves the share at start-up.
    • off disables it.
  • Memory. Partitions are charged to the CSR share (E/9) before they grow; a partition that does not fit is refused and the query keeps the reference reader. Dense visited sets are charged to the query’s memory scope; when that reservation is refused, the traversal falls back to its hash-based state.

Measured sizes and effects (owned SF1 copy, in-process, #1442):

  • One KNOWS partition was charged 3,074,896 bytes per direction and took 5.4 s (forward) and 4.5 s (reverse) from request to ready (RFC 0042 D1).
  • All 30 SF1 partitions together were charged 989,749,944 bytes and took 104 s of sequential warm construction (RFC 0042 D2). This is not cold startup or RSS.
  • Under auto, a *1..3 count(DISTINCT …) BFS ran at 0.42 of its off time (34.66 ms to 14.49 ms); IC1, IC13 and IC14 did not engage and stayed within 0.95–1.04 (RFC 0042 “D auto”).
  • With on, a generated 1,024-vertex graph written at 1,000 writes/s became fully dirty and read throughput fell from 341,527/s to 279,626/s (RFC 0042 D2). This is why fixed hops are not in auto.

EXPLAIN neither builds nor reports a CSR; PROFILE shows csr.admission and traversal.source decisions. See How the Query Planner Works → CSR adjacency cache.

Memory model

Regions

At start-up the server resolves one memory budget (Figure 4; source crates/patinadb/src/resources/config.rs):

  1. Effective RAM (TOTAL) is the smallest of host RAM and every visible cgroup limit (v2: every ancestor’s memory.max; v1: the hierarchical limit). If discovery is incomplete the server does not guess: it requires an absolute PATINADB_MEMORY_LIMIT.
  2. Page-cache floor PATINADB_CACHE_MIN_FREE, default max(1 GiB, TOTAL/10): memory left to the kernel’s page cache and other processes.
  3. Own heap PATINADB_MEMORY_LIMIT, default TOTAL − floor, divided into the cache pool (40%), work memory (45%) and headroom (15%).
  4. Work memory is divided into apply (25%, committed Raft entries, replay and recovery), background (10%, optional maintenance scratch) and foreground (the rest, client statements). A client transaction may hold at most min(256 MiB, foreground/4); one statement at most min(128 MiB, transaction/2).

Each region accepts an absolute size, a percentage of its parent, or auto; the four work budgets (PATINADB_APPLY_MEM_LIMIT, PATINADB_BACKGROUND_MEM_LIMIT, PATINADB_MAX_TX_MEMORY_BYTES, PATINADB_MAX_QUERY_MEMORY_BYTES) accept sizes or auto only. Startup refuses a partition that over-commits. A ceiling of 1 GiB or less leaves no own heap with the default floor and is refused. For a 2 GiB ceiling the defaults give a 1 GiB own heap, a 409.6 MiB cache pool, 51.2 MiB per core cache level, a 78,517,370-byte transaction cap and a 39,258,685-byte statement cap (calculated; the same arithmetic as Configuration). From an 8 GiB ceiling on, the transaction and statement caps stop growing at 256 MiB and 128 MiB.

Embedded use (the patinadb library without the server) installs no work-memory governor and starts with caches off. Setting PATINADB_CACHE_LIMIT enables the decoded caches with that size as their whole budget, without a page-cache floor and without the sampler.

What is accounted, and what happens on refusal

The work budgets count reservations on covered paths, not allocations. The server gives every Bolt statement a query scope inside its transaction scope (autocommit statements get a one-statement transaction); HTTP /cypher reads and writes are admitted too.

When a reservation is refusedPaths
the statement fails with Neo.ClientError.Statement.ResourceExhaustedfull sorts, materialized results, row building, UNION/DISTINCT accumulators
the query continues on the reference pathfactorized aggregation, CSR visited sets, cost-model catalogs (16 MiB each), the covering-input spool
not byte-accounted; bounded by row counts insteadhash-join build sides and plain GROUP BY (PATINADB_MAX_AGG_ROWS, default 5,000,000), cartesian products (PATINADB_CARTESIAN_CAP, default 10,000)

The refusal message names the setting and the numbers: memory reservation refused: scope exhausted; PATINADB_MAX_QUERY_MEMORY_BYTES=…, accounted=…, requested=…, phase=…. A refused statement has no effect (Bolt reports patinadb_effect: "none"). A write is also checked before it is proposed to Raft: its counted data, times six plus 64 bytes, must fit the apply allowance. A replica never refuses an entry that is already committed. Refusals are counted in patinadb_resource_cap_rejections_total (label cap names the setting), and the pools in the patinadb_memory_* gauges (see Cache Observability → Accounted work memory).

On Linux the server rechecks the visible memory limits every two seconds. If a limit drops below the startup bound, new foreground and background reservations are refused (patinadb_memory_admission_stopped = 1) until the node is restarted with a valid configuration. Committed apply is never stopped.

The governor sampler and allocator reclamation

While the decoded caches are enabled, the server samples available memory every second. The sample is host MemAvailable capped by every readable cgroup-v2 ancestor’s remaining allowance, crediting only clean inactive file pages; dirty, writeback and shared-memory pages count as used. Below the page-cache floor the governor evicts cache entries.

On Linux with glibc the same sampler asks the allocator to return freed pages to the OS (malloc_trim) every 30 seconds, and at most once a second while below the floor (#1306). This was added after a soak in which retained free allocator arenas, not live data, exhausted the memory reserve (#1306). It frees nothing that is in use and cannot release fragmented pages. With the decoded caches off there is no sampler and therefore no trimming.

None of these mechanisms is a hard RSS limit. Native pages, allocator slack, encoded network buffers and paths not listed above are outside the counters; keep cgroup and process memory under observation.

Durability and recovery

  • Write path. The leader resolves a writing statement against a throwaway mirror of the graph, then proposes the resolved operations as one Raft entry. The entry is appended to _raft/patinadb.redb in one durable redb transaction before it counts toward the commit. Each node applies committed entries to its database file as one crash-atomic unit per entry, together with its Engram history, so an entry lands completely or not at all.
  • Group commit. Several committed entries handed to apply together share one deferred-durability scope and then one durability barrier (one fsync) per touched database, before the applied index is persisted. A crash before the barrier rolls the whole batch back to the previous durable root, and the entries are applied again from the log. Entries that change schema or databases apply one by one. PATINADB_RAFT_GROUP_APPLY=0 disables grouping.
  • Quick repair. Every durable commit uses redb’s quick-repair mode, so reopening after a crash does not need a full-file repair scan (#1307). At start-up, after any pending snapshot install is recovered and before Raft starts, each database gets one empty durable commit that moves pending native cleanup in front of client admission.
  • History snapshots. Each database writes an Engram snapshot body every 50 commits or once 1 MiB of deltas has accumulated (PATINADB_SNAPSHOT_DELTA_BYTES), so time-travel reads replay a bounded delta chain. Periodic snapshot scans are paced at 16 MiB/s by default (Configuration).
  • Raft snapshots. Every 5,000 log entries (PATINADB_SNAPSHOT_LOGS_SINCE_LAST) the node takes a Raft snapshot. When every member reports protocol 22 it is physical: a fresh redb checkpoint of each database’s state tables, built from a snapshot pinned at the cut, plus history streams. Otherwise it is logical: record streams that the receiver replays. Afterwards the log prefix is purged: the purge floor is written durably first and the physical deletion follows in small batches.
  • What a restart keeps: database files, history tables and snapshot bodies, the Raft log, vote and applied index, Raft snapshots, the audit file and an enabled L4 file.
  • What a restart loses: every RAM cache, membership sets, CSR partitions, planner statistics, the degree catalog and the plan, parse and A2 entry-decision caches. The first queries after a restart are slower until these are rebuilt; results are the same.

Operational detail: Day-2 Operations (startup recovery, disk reclamation), Disaster Recovery and Limitations → Mixed workloads under severe RAM pressure.

Practical guidance

Sizing RAM against data

There is no measured general formula; size from measurements of your own data. These measurements give orientation:

  • File size per relationship. On a 200,000-edge generated fixture, the whole compacted file used 66.6 allocated bytes per adjacency entry with empty values and 70.8 with inline labels; every relationship has two entries (RFC 0040 1.B2, #1442). Real files also hold properties, indexes and history.
  • A real store. The compacted LDBC SF1 store (3.18 M vertices, 17.3 M relationships) used 10,114,277,376 allocated bytes before and 10,183,208,960 after the schema-8 inline rebuild (RFC 0040, RFC 0042 D0, #1442).
  • Working set, not file size, decides speed. In the Velrbench A1 runs below, the query’s L2 adjacency working set was 79.4 MiB of accounted L2 bytes (295,439 lists) at 8 GiB (#1442, docs/benchmarks/storage-manual). With the 2 GiB defaults, L2 is capped at 51.2 MiB (calculated), and the query took more than twice as long.
  • A small budget can still be stable. A five-node, three-hour mixed soak with at least 10 GiB of allocated graph files per node and a hard 2 GiB memory limit per node passed its original deadline, fault and OOM checks (#1409; see Limitations). It used configured background pacing and synthetic data; it is not a latency guarantee.
  • Measure allocated bytes. redb grows files copy-on-write and does not shrink them; use du (not du --apparent-size or the file length) and reclaim space offline with patinadb <db> compact (Day-2 Operations).

Read Figure 4 the other way round when sizing: decide how large the L2 and L1 shares must be for the hot part of your graph, multiply by 8 to get the cache pool, and divide by 0.4 for the own heap. Leave the page-cache floor for the kernel, which caches the file pages that the native caches miss.

Signs of cache thrash

Figure 5. The same query and plan under different memory budgets: Velrbench A1, one query thread, L3 and L4 off, cores 0–3, 2026-10-16, main 81e0ab06 (RFC 0042 B.4, #1442; data in docs/benchmarks/storage-manual/).

What the runs show:

  • Same plan, same rows, twice the time. At 2 GiB the L2 share (51.2 MiB, calculated) is smaller than the 79.4 MiB working set. The query cycles through its adjacency lists, so they pass admission, evict each other and miss again: hit rate 0.58 and about 639,000 evicted entries per query. In the original diagnosis, PROFILE counted 481,387 backend read requests per run at 2 GiB against none at 8 GiB (RFC 0042 B.4, #1442).
  • The budget, not the cgroup, is the cause. An 8 GiB cgroup with the application budget of a 2 GiB host (PATINADB_MEMORY_LIMIT=1GiB, PATINADB_CACHE_MIN_FREE=1GiB) is as slow as the 2 GiB cgroup, and forcing redb’s page cache down to 13.65 MiB at 8 GiB changes nothing. No refaults or major faults were observed (RFC 0042 B.4, #1442).
  • More memory past the working set does not help. 3, 4 and 8 GiB all run in about 2 s with hit rate 1.0 after the first call.

Look for these signs on your own workload:

  • a hot level near its cap in GET /mgmt/cache or patinadb_cache_bytes, with patinadb_cache_evictions_total and patinadb_cache_misses_total rising while the query mix is stable;
  • a repeated query whose PROFILE Storage I/O footer reports nonzero normal reads on a warm node (these are requests that missed redb’s page cache; cache hits never reach this counter);
  • evicted_free_floor_bytes rising in /mgmt/cache governor, which means the page-cache floor, not the cache cap, is forcing evictions.

What to read

QuestionWhere
How much does each cache hold, and does it hit?CALL patinadb.cache.stats(), GET /mgmt/cache, patinadb_cache_{bytes,entries,hit_ratio,hits_total,misses_total,evictions_total} per level (reference)
Is the governor evicting for the floor or for the cap?patinadb_cache_evicted_free_floor_bytes_total, patinadb_cache_evicted_cap_bytes_total
Are writes invalidating cached data?patinadb_cache_gen_invalidations_total
Is work memory refusing queries?patinadb_resource_cap_rejections_total, patinadb_memory_pool_refusals{pool}, patinadb_memory_admission_stopped
Did this query reach the file?PROFILE Storage I/O footer (backend requests on the query thread, not physical disk bytes)
Where did a traversal read its neighbours?PROFILE decisions traversal.source (csr, l2, redb) and csr.admission
Which read-ahead policy does a store use?EXPLAIN Storage: line (Query Planning)
Is L4 used?patinadb_l4_victim_*, the l4_victim block of /mgmt/cache

patinadb_mem_available_bytes reports host MemAvailable; the sampler’s own cgroup-capped estimate can be lower.

Knobs to try

Try one at a time and compare with a paired measurement.

SymptomTryNotes
A hot level at its cap, high evictionsraise PATINADB_CACHE_LIMIT (or the node’s memory)grows native and decoded halves together; cache + work + headroom must still fit the own heap
L3 never hits (few repeated queries) while L2 is at its capPATINADB_L3_MAX_RESULT_BYTES=1L3 off; the other three core levels get E/3 each; restart needed; no measured speedup yet (RFC 0042 B.4, #1442)
Expensive repeated reads lost on eviction or restartPATINADB_L4_VICTIM_MAX_BYTESdisk tier; see Caching → L4
Huge adjacency lists flooding L2keep PATINADB_CACHE_ADJACENCY_MAX_EDGES at its default 8,1920 removes the ceiling (not recommended)
Memory floor evictions on a read-heavy nodelower PATINADB_MEMORY_LIMITleaves more RAM to the kernel page cache
Statements refused for memoryraise PATINADB_MAX_QUERY_MEMORY_BYTES / PATINADB_MAX_TX_MEMORY_BYTES within foregroundor add LIMIT, or reduce materialization
Deep BFS traversalsPATINADB_CSR=auto (shipped) or on for shortest pathson reserves the share at start-up; check write rate first
Diagnosing a cache-related differencePATINADB_CACHE_LIMIT=0, PATINADB_CSR=off, PATINADB_PRIMARY_POINT_IO=offfor comparison only; restart required

Known limitations

  • Memory budgets are reservation counts on covered paths; native pages, allocator retention and some buffers are outside them. There is no hard RSS limit (Limitations → Transaction memory admission).
  • Cache shares are fixed at start-up: an idle level does not lend its share to a busy one, and auxiliary caps are not reserved within E (docs/arch/caching.md records the deferred design).
  • A cyclic working set larger than its level thrashes despite W-TinyLFU (Figure 5).
  • Invalidation is per label and property, not per vertex: write-heavy labels rarely keep cache entries.
  • One redb file per database on one machine; no sharding (Limitations → Storage & scale).
  • redb files do not shrink online; compaction is offline.
  • Statistics and the degree catalog are rebuilt after every restart.

Sources for the figures

The figures are generated by scripts/docs_charts/storage_figures.py (standard library only, deterministic; --check verifies the committed files). Figures 1 and 2 are diagrams checked against the source. Figure 4 evaluates the budget formulas of resources/config.rs and cache/level_budget.rs. Figure 3 reads the A2-cal v1 constants from plan/cost/constants.rs. Figure 5 reads docs/benchmarks/storage-manual/mem-2g-a1.json, an extract of the RFC 0042 B.4 coordinator runs described in that directory’s README (#1442).

Caching & Memory Tuning

Query normalization is an optimization, not an alternative syntax. Queries containing comments, backtick-quoted identifiers or non-ASCII text outside string literals use the original parser/cache key rather than literal-shape sharing. Unicode string values remain supported. Use explicit parameters for repeated application queries; neither cache hits nor misses may change results or result-column names (#1264).

For the structured overview of the on-disk layout, the hop read path, every cache level and the memory budget hierarchy, see How Storage Works.

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 the L1, L2 and L3 RAM caches engaged. The L4 disk tier stays off until you set PATINADB_L4_VICTIM_MAX_BYTES. Turn the RAM caches off with --cache-limit 0 (or PATINADB_CACHE_LIMIT=0, off, disabled, or cache_limit: "0" in the config file), any of which still win over the default. Cache-off is not a zero-cost mode: it disables the decoded caches and A2 entry-decision reuse. redb’s native page caches keep their automatic share (40% of PATINADB_MEMORY_LIMIT), the work-memory governor stays active, and the parse and plan caches keep working.

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 disables the decoded levels, while native page caches and work memory remain). A fifth, L4 disk-backed victim tier for expensive L3 results also ships, opt-in via PATINADB_L4_VICTIM_MAX_BYTES (off by default).

Eviction in decoded-object, vertex-property, adjacency, query-result and edge-property caches samples occupied entry positions (#1476). The cache budget, frequency admission, generation validation, database-share preference and L4 spill settings retain their existing meanings; no new tuning option is required. The combined change still needs whole-query and memory-pressure qualification; cache byte accounting remains an estimate rather than a bound on process RSS.

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: postcard / encode_for_index → Vertex / 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).

Discovery is all-or-nothing on the server. If any part of it fails (no readable cgroup membership or mount, an unreadable or malformed limit, no memory controller, a hierarchy that changes during discovery), TOTAL is unknown. The server then does not guess: it refuses to start unless PATINADB_MEMORY_LIMIT is an absolute size, PATINADB_CACHE_MIN_FREE defaults to 1 GiB, and an explicit ceiling must still fit any limit that was observed.

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.

Cache and work memory are separate allowances

The cache pool and work memory (“actions”) are fixed partitions of MEMORY_LIMIT, resolved once at startup. A query never evicts cache entries to obtain working memory: a work reservation that does not fit its query, transaction or pool allowance is refused (see Configuration). The only pressure between the two is indirect: when measured available memory falls below PATINADB_CACHE_MIN_FREE, the governor evicts cache entries (see The governor). Two nested guards are checked at startup: an internal one (cache + actions + headroom ≤ MEMORY_LIMIT) and an external one (MEMORY_LIMIT + CACHE_MIN_FREE ≤ TOTAL).

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).

The server logs two INFO events at startup: memory discovery, with what was found and any discovery issues, and resolved memory and native startup plan (bytes), with every resolved region and the per-file native cache plan. They are logged whether or not the entry caches are enabled. GET /mgmt/cache echoes the budget (total, cache_limit, work_mem_limit, headroom, min_free), and --check-config prints the same resolution without starting the node.

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. While the decoded caches are enabled, a background sampler estimates available memory once a second: host MemAvailable (/proc/meminfo), capped at every readable cgroup-v2 ancestor by that ancestor’s remaining allowance, crediting only clean inactive file pages (dirty, writeback and shared-memory pages count as used). So pressure inside a container is visible even when the host has RAM to spare.
  • 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. A candidate must also have been seen at least twice before it is admitted at all. Eviction is sampled recency: a level samples eight resident entries, evicts the least recently used of them, and repeats until enough bytes are free. The sample is biased towards databases that hold more than their equal share of the level. When the governor must free bytes across levels (floor or cap), it takes them from each level in proportion to its resident bytes. There is no cost or value-density weighting. On top of the frequency filter, the L2 adjacency level applies a size ceiling (issue #808): a fully-drained incident-edge list longer than PATINADB_CACHE_ADJACENCY_MAX_EDGES (default 8192 edges, 0 disables) is never admitted. This bounds the recording buffer and prevents one large incident list from consuming the cache share intended for reused anchors. Cached lists retain the optional target-label IDs decoded by the storage scan. Eligible property-free target checks reuse these IDs; secondary-label membership is still checked, and legacy admissions without IDs retain the normal target lookup. Cache accounting charges the capacity of both backing arrays and their Arc/Vec allocations; it remains an estimate of allocator and map overhead, rather than an exact process-memory measurement. No performance improvement is claimed for this representation until its correctness follow-up and native comparison are complete.
  • Node-local, never stale. Caches are strictly node-local — nothing travels over Raft, and there is no cross-node coherence protocol. Correctness comes from snapshot-relative generation tags. L1 objects depend on a structural label generation; L1 properties depend on that generation and the generation of their specific (label, property). Overwriting an existing summary in an ordinary deferred atomic write therefore need not discard cached labels or unchanged issue_key values. Direct non-deferred physical commits additionally change a per-open certificate on L1, L2 and edge-property entries. This conservatively rejects reuse across such a commit even for unchanged dependencies. The certificate belongs to the actual native read snapshot, so an older pinned reader cannot republish a value for a newer pin in the interval before logical sync(). A pin racing publication bypasses these caches. L3/L4 additionally stamp a persisted physical-mutation revision from the same read snapshot. This survives reopen and prevents a spilled old result from validating before logical sync(). Old L4 entries without that certificate miss. Failed reads and counter exhaustion bypass result caching. This is not a new atomicity guarantee for direct writes. Create/delete, label churn, property addition/removal and conservative bulk paths still invalidate the broader structural scope. This is not per-entity invalidation: overwriting a property invalidates that property’s entries for the label. L2 retains its existing dependencies. L3/L4 use structural and property dependencies for supported scalar query plans: properties read only by a filter, ordering or aggregate still count. An overwrite of an existing, unread property can preserve a result hit. Whole entities/property maps, ambiguous bindings and unsupported plan shapes retain broad label invalidation. Traversed edge types keep broad type epochs. Property writes cover primary and secondary labels; changing the secondary label set invalidates its old/new populations and the primary entity scope. Generations are updated through the transaction’s metadata and read from the querying transaction’s snapshot, not from a global latest-version counter. A mismatched entry is a miss, not permission to serve an old value. 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 record decode and label-registry resolve 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 incident-edge writes and checked against snapshot-relative relationship generationsshipped (increment 2)
L3 resultsMaterialized hot, pure-read, deterministic parameterized results (≤ 1 MiB, seen ≥ 2). Supported scalar plans stamp structural/property dependencies; unsupported analyses retain broad label scopes. Typed traversals stamp every traversed edge type. Untyped edges, unlabeled scans, path bindings, procedures, LOAD CSV, correlated subqueries and non-deterministic expressions are not cached.shipped, finer dependencies in this increment
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

L3’s query-shape admission history is bounded separately from result payloads: 16 fixed shards hold at most 256 counters each (4096 counters across all node-local databases). An unseen shape arriving at a full shard resets that shard’s history. A forgotten shape must satisfy the configured repetition threshold again; valid cached results and their generation checks are unaffected. Database invalidation and cache reset also discard the corresponding history.

The plan cache (512 plans), parse cache (1,024 entries) and planner statistics cache (4,096 entries or 4 MiB) have fixed caps and are not governor-accounted. The A2 entry-decision cache is governor-visible as planner.entries (#1467). It retains at most 64 entries and min(Q/16, 4 MiB) estimated bytes, where Q is the governor’s auxiliary cache quantum after native/CSR adjustments. Keys are estimated and reserved before copying; oversize or unsupported keys use the ordinary cost model. No new environment switch is needed: PATINADB_CACHE_LIMIT controls this region too, and zero disables it. Its lookup and publication skip a contended mutex; metadata validation runs outside that mutex (#1466), retaining the normal dependency checks.

The level’s byte count includes construction and evicted entries still held by a reader. Per-database statistics list only entries still in the directory, so their sum can be smaller. Hit/miss counters for this region are not collected (the reported zeros are not a measured hit rate). Accounting uses the shared value model, including map key text, named time zones and polygon ring headers; it estimates ownership, not exact allocator consumption or RSS. Besides the four layers, the governor accounts the membership, edge-property, CSR (derived.csr) and A1 degree-catalog (stats.degree) regions; see How Storage Works → Caches.

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 dependency stamp L3-RAM uses — persisted beside the payload. A write that advances a recorded structural, property, broad-label or edge-type generation makes the entry miss and be recomputed. Unknown dependency encodings miss too. This increment uses disposable L4 format version 3: older victim files are discarded on open and warmed again, without changing the database format.
  • 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 dependency 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. Tune this policy using observed reuse and latency; it is not a measured break-even guarantee for every storage device.
  • 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. Its database handles and the OS page cache still consume memory. The cap applies to accounted entries per database, not physical file allocation; allow for storage-engine overhead and sum the budget across nodes/databases.
  • Off the hot path. L4 is read only on an L3-RAM miss and written only on the eviction or RAM-admission rejection path. RAM-L3 hits do not consult L4. It inherits L3’s admission analysis verbatim (untyped-edge / unlabeled-scan / path-binding / procedure / correlated-subquery / 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_BYTESaccounted entry-byte cap per database (0 = disabled); not a physical-file quota0 (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.

An enabled L4 is not proof that the workload uses it: require actual spills and valid hits, and test reuse after restart. Under heavy writes, property-wide dependencies can invalidate results even when the changed node would not match the query predicate. L4 is not a replacement for sufficient RAM or a query deadline policy. The local ten-hour qualification (#1261, repository docs/cache-result-dependencies-1261.md) exercised L4 under mixed traffic; separate undersized-RAM trials still exceeded the backend deadline. Full-capacity L4 eviction and OS-cold L4 latency were not qualified by that run.

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. Disabling is a fully supported, tested configuration. It disables the decoded caches, A2 entry-decision reuse and the sampler (and with it the periodic allocator reclamation); native page caches, work memory and the count-bounded plan and parse caches remain.

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 — entries for churny data are invalidated before they are reused, and they are evicted like any other cold entry. The win therefore depends on the workload: repeated reads of rarely written data benefit, write-saturated labels gain little.
  • 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.

L1 property entries can also record that a vertex property is absent. Repeated reads can reuse that result under the same admission and memory budget as present values. SET, REMOVE and graph replacement invalidate it using the existing property generation and cache invalidation rules (RFC 0040, #1442).

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.

How the caches fit into the storage layout and memory budget, and a measured example of cache thrash, are in How Storage Works.

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. The application-cache surfaces below then report disabled/zero entry accounting. Native redb caches and query work memory are separate; cache-off does not establish zero process overhead or disable the work-memory governor.

Accounted work memory (RFC 0036)

The existing authenticated GET /metrics endpoint also exposes the following work-memory gauges. They reflect the node’s work governor, which the server installs at startup by default from the resolved memory budget (embedded library use has none unless the caller installs one). They do not measure RSS, native allocator retention or the kernel page cache.

GaugeMeaning
patinadb_memory_governor_enabled1 if this App has a work governor, otherwise 0
patinadb_memory_admission_accepting1 while new work admission is enabled, 0 after it is stopped, -1 when no governor is installed
patinadb_memory_accounted_bytes{pool}Bytes currently owned by accounted reservations
patinadb_memory_accounted_peak_bytes{pool}Highest simultaneous accounted reservation total since this governor was created
patinadb_memory_pool_limit_bytes{pool}Configured accounted allowance for this pool
patinadb_memory_pool_refusals{pool}Cumulative pool-refusal count exposed as a snapshot gauge; resets with the governor

The fixed pool labels are foreground, apply and background. Scrapes refresh these values from the same governor used by execution. With no governor, all four pool gauges are NaN, not an observed zero. Cache-off and governor-off must therefore be checked separately. Authentication and the existing Admin role requirement are unchanged.

A foreground value of zero after client quiescence proves that no foreground reservations remain at that scrape. It does not prove that every server worker has joined or every allocation is accounted. A nonzero peak is not a measured process-memory peak. Keep cgroup/process measurements alongside these gauges when investigating memory pressure.

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, l1.label_membership, l1.edge_properties, l2.adjacency, l3.results, derived.csr for CSR adjacency partitions, and stats.degree for the A1 degree-catalog region); {db} is the numeric database id. derived.csr reports one scope per relationship type. stats.degree is governor-accounted but reports no per-scope rows, so its resident bytes do not appear in the per-level byte, entry or utilization figures.

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 entry——levels[].avg_entry_bytesSizing sanity check — an L3 result row is far larger than an L1 object
The resolved hard cappatinadb_cache_limit_bytes—budget.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 level——levels[].evicted_bytesThe byte-weight of the per-level LRU churn
Bytes evicted to protect the page-cache floorpatinadb_cache_evicted_free_floor_bytes_total—governor.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_total—governor.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_total—governor.admissionsThe steady flow of newly-cached decoded artifacts
Candidates rejected (scan-resistance)patinadb_cache_rejections_total—governor.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 invalidation——levels[].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_bytes—budget.min_freeThe promise to the OS: keep at least this much system RAM free for the storage engine’s page cache (L0)
Host free RAM (MemAvailable)patinadb_mem_available_bytes—mem_available_bytesRaw host /proc/meminfo MemAvailable. The sampler’s own estimate caps this by every readable cgroup-v2 ancestor’s remaining allowance (crediting only clean inactive file pages), so inside a container it can be lower than this value. Hovering near min_free = pressure
Own-heap ceiling——budget.totalpatinaDB’s own-heap limit (excludes the OS page cache)
Action reserve——budget.work_mem_limitRAM reserved for concurrent query working memory
Headroom——budget.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.

The admin dashboard’s Cache → L4 · Disk result cache panel refreshes with the existing four-second cache poll. It shows accounted disk bytes, entries, valid hits, spills, stale drops, LRU evictions, RAM promotion attempts and the minimum recompute-cost threshold. Counters and bytes aggregate the open databases on the selected node; the displayed cap is per database, not a node-wide capacity. Accounted bytes are not physical file size. Promotion attempts do not guarantee RAM admission. No L4 hit percentage is inferred from stale drops, since absent-key misses are not reported in this block. Missing metrics are shown as disabled/not reported, including older servers; fetch errors do not display old L4 values as live. L4 is displayed separately even without a RAM governor.

MeaningPrometheuscache.stats/mgmt/cacheHow to read it
Accounted disk entries / bytespatinadb_l4_victim_entries / patinadb_l4_victim_bytesl4.victim row (entries/bytes)l4_victim.entries / .bytesAggregated across open databases; max_bytes applies per database, not to the sum
Valid disk hits servedpatinadb_l4_victim_hits_totall4.victim row hit_rate = hits/(hits+stale_drops)l4_victim.hitsEach hit skipped a full recompute; RAM admission is separate
RAM promotion attemptspatinadb_l4_victim_promotions_total—l4_victim.promotionsEligible L4 hits attempt L3 admission; the RAM governor can still reject admission
Victims spilled to diskpatinadb_l4_victim_spills_total—l4_victim.spillsExpensive results caught on evict/reject. spills ≫ hits = 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_total—l4_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_total—l4_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, db:1/structure:Ticket, db:1/label:Ticket/property:summary, or db:1/shape:1234 (a plan fingerprint).
  • kind — the cache level (l1.objects, l1.properties, l1.label_membership, l1.edge_properties, l2.adjacency, l3.results, derived.csr, and — when the disk victim tier is enabled — a single l4.victim pseudo-scope row). stats.degree yields no rows.
  • bytes / entries — resident size for that (level, scope).
  • hit_rate — hits / (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 no rows for resident entries, never an error. Levels can still be registered while disabled: under the default PATINADB_CSR=auto, adjacency writes register l2.adjacency and derived.csr for write tracking. 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 the entry caches are disabled on a server, enabled is false and budget.cache_limit is 0, but budget.total, work_mem_limit, headroom and min_free still show the resolved memory budget. levels holds no resident entries; it may list levels registered for write tracking (see above). 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_free → lower 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. The voter cap is enforced on both paths that can grow the voter set: POST /mgmt/change-membership and the cluster’s very first POST /mgmt/init bootstrap call (issue #1075 — a Community node used to be able to bootstrap a multi-voter HA cluster in one /mgmt/init call and never touch change-membership again, making the cap a no-op on that path).

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.

This is retention of delta history, not of the periodic .snap files (issue #1098). A DIFFERENT, separate mechanism — PATINADB_MAX_SNAPSHOTS — bounds the on-disk COUNT of periodic full-graph snapshot files (_engram_snapshots/*.snap, one roughly every 50 commits); this section’s retention window does not touch it (a squash only prunes entries older than its cutoff, so .snap files inside the window still accumulate at the per-commit cadence regardless of tier). See Retention & disk for the knob and the measured cadence math.

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-telemetry—disable_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.

Clock dependencies (issue #1076) — run NTP

Two pieces of this node’s behavior compare wall clocks across a network, and both need this node’s clock to be right, especially across regions (more independent NTP failure surfaces, more nodes):

  1. The telemetry response-freshness check (above): server_time must be within ±5 minutes of this node’s own clock. A community node whose clock has drifted more than that treats every heartbeat as a failed one — with a healthy network — and degrades to read-only after the grace window, the same as a genuine outage would.
  2. License expiry is evaluated against this node’s own local clock (now_secs()), not a cluster-wide agreed time. Right at the expiry boundary, nodes with different clock skew can accept or refuse a growing write / membership change differently — visible in a cluster as “sometimes 403, sometimes OK” depending on which node handles the request.

Run NTP on every node. Neither of these is a correctness bug in the sense that graph writes, AS OF reads, or retention ever use wall-clock time for anything replicated (engram timestamps are leader-stamped and travel inside the replicated Raft entry — every replica sees the identical value; AS OF resolves only by engram id/tag, never by wall clock) — but both are availability and admission-consistency failures that a broken NTP setup causes silently.

Telling “my clock is wrong” from “the network is down.” A failed heartbeat used to log and count identically regardless of why it failed. As of issue #1076, GET /metrics exposes patinadb_telemetry_heartbeat_failed_total, labeled by reason: clock_skew, missing_signature, malformed_signature, bad_signature, network. A rising reason="clock_skew" count means fix NTP on this node (or check the telemetry server’s own clock); a rising reason="network" count means fix the network route. The clock-skew case also gets its own named log line (CLOCK SKEW vs telemetry server) instead of the generic “failed signature verification” warning every other reason shares.

Catching a badly wrong clock before it ever boots. patinadb-raft --check-config decodes the installed license (if any) and flags a node clock that reads before the license’s own issued_at — a license is never issued in the requester’s future, so that combination can only mean this node’s clock is wrong. Run it as part of your deploy pipeline, the same as any other --check-config validation.

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

Interrupted squash cleanup

Before a legacy squash writes its new genesis, it durably records the genesis and the history prefix it is about to retire. An asynchronous (experimental) squash records the same after it publishes. If a crash, or a retry of the squash, interrupts the cleanup, the next open of the database deletes what is left: records first, then snapshot files. A later squash does the same. Engrams that are reachable from HEAD, pinned, tagged, fenced by a committing squash or written by a squash still in progress are kept. Until the next open or squash, the leftover still uses disk space. An install or restore that replaces the engram log also drops the record. The cleanup removes at most 16 entries per synchronous block and makes record deletions durable before unlinking snapshot files (#1512).

Late write admission qualification — #1504

Pipelined ordering/read-index waits now honor the remaining query deadline. REST pipelined writes (/cypher autocommit and bulk), which answer a refusal with a not_submitted receipt, are also bounded by the bookmark-wait budget. Bolt writes are not, because Bolt has no such receipt. Focused controls cover apply lag, ordering contention, expired deadlines and disabled budgets.

A legacy synchronous CALL patinadb.squash runs on the shared serial apply path. In the 4 GiB pressure soak it held that path for 1–2 s routinely and up to 15 s (#1495 soak runs). Since the batched cleanup, the longest apply entry near each squash call was 0.19 s p50, 1.12 s p99 and 3.62 s max over 651 calls (#1507, full soak 0185c2321912). Writes to every database still wait behind it, and REST writes with a short bookmark-wait budget are refused while it runs. Tracked in #1507. This bounds admission before proposal; it does not cancel an already proposed mutation or authorize replay after an ambiguous client timeout. See the write retry contract.

Scoped procedures in write mirrors — 2026-10-21

The extended direct-client smoke reproduces a real CALL/YIELD/SET failure: WASM graph reads succeed on all five nodes, but the write mirror reports the registered function as unknown. Procedure lookup used the temporary store id instead of the source dataset’s registry namespace. The candidate carries the dataset id in the existing scoped procedure context; physical/cache ids stay unchanged and lookup never searches another database. A small regression fails before the fix and passes afterward, covering embedded writes, leader resolve, pinned-transaction resolve, two same-named database registrations and refusal in an unregistered database. Nested panic restoration also passes. Broader checks, new binaries and the extended smoke/full pressure qualification remain pending. The historical smoke/full failures remain failures; no three-hour PASS yet.

Limitations

The latest source-admission increment (#1478) has completed a serial 32 GiB SF1 comparison with result caches excluded. Performance acceptance remains open: IC1 regressed in both comparison pairs, and a separate first-request IC9 diagnostic timed out before profiling. This is not a new low-memory qualification. The subsequent FOREACH/DML copy changes pass their source regression gates; their performance and memory-pressure qualification remains open.

The development correction for #1479 preserves unique projected scalar names containing a dot in ORDER BY, including WITH/RETURN, DISTINCT and bounded ordering. Batched sort-key lookup preserves the first exact scalar alias, including NULL. Ordinary property ordering retains its existing execution path. These paths have regression coverage; their runtime performance remains unqualified.

Offline patinadb import writes current datastore state without creating an Engram history baseline. Whole-import rollback atomicity, historical queries and subsequent replica recovery are not implied by successful import/reopen. CSV typed lists are supported by import; Arrow List input and lossless list export/import round trips remain unsupported. See Bulk Loading.

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.

Direct storage writes and cache retention

Deferred publication remains under correctness qualification (#1483). Deterministic controls reproduced both stale decoded-cache fields and a successful Cypher result mixing two versions between physical batches of an outer write scope. The development candidate passes the cache regression controls and adds snapshot admission before read execution: an uncertified native snapshot is refused before execution with READ_PUBLICATION_IN_PROGRESS (HTTP 503, retryable; transient Bolt status), while already admitted readers keep their stable native version. The diagnostic read-snapshot reuse toggle cannot remove that admitted-read isolation.

Focused controls also cover legacy reads with memory accounting disabled and parallel workers retaining the admitted snapshot. Standalone owned snapshots, including follower explicit transactions, refuse an overlapping uncertified publication rather than pinning an intermediate version. Internal write-scope callers retain their own read-your-writes behavior. Broad source validation, including crash/reopen, disk-fault and bookmark tests, passes. A source-bound cluster smoke and the new five-hour pressure run are still pending; runtime correctness qualification is not complete. The prior fulltext oracle failure remains a failure; its exact response was not retained, so these controls do not prove its cause. A passing small diagnostic does not clear that large-run failure.

The independent Rust customer backend handles explicit-transaction admission separately: an exact server DatabaseUnavailable or BookmarkTimeout rejection during ROUTE or BEGIN may retry within the original request deadline, before any statement is sent. A late BEGIN success cannot start a write. RUN/COMMIT failures and transport loss are not automatically replayed by this handling. This does not make an HTTP 503 after a write a rollback acknowledgement; its outcome still requires readback. The cluster pressure qualification remains open.

On the leader, BEGIN waits for the database’s in-flight writes to finish applying before it pins its snapshot. That wait now ends at the transaction deadline (tx_timeout or --query-timeout) with DatabaseUnavailable and no effect, instead of spending the deadline and failing the first statement. Under sustained storage pressure such a refusal can still occur; retry the transaction.

The decoded-cache publication fix covers L1 objects/properties, L2 adjacency and edge properties for non-deferred physical commits before logical sync(). Those commits conservatively invalidate reuse across the physical database; ordinary deferred atomic writes retain dependency-based invalidation. This does not make a sequence of direct storage calls atomic. L3/L4 result stamps also carry a durable physical-mutation revision from the reader’s native snapshot. Direct mutation commits update that revision in the same redb transaction; normal deferred writes and empty repair commits do not. This adds a counter update to direct mutation commits and one memoized read per native query pin using L3/L4. The performance impact is unqualified. Entries spilled by older versions without this certificate are rejected and recomputed. This qualification covers non-deferred direct commits, not arbitrary mid-scope deferred flushes.

Experimental cyclic joins require explicit activation

PATINADB_WCOJ=on enables the RFC 0042 base-adjacency cyclic join candidate. auto is the default since 2026-10-17 (#1442); it admits only closing probes and intersections by cost, never the cyclic join, and off restores binary traversal. Admission is limited to connected fixed-edge regions with at most sixteen vertex variables and sixteen typed relations. OPTIONAL, variable-length paths, derived inputs and scans carrying ordering promises retain existing execution. Named and property-filtered relationships use binary probes within an admitted region.

Plain COUNT can accumulate without binding rows. Other consumers require a complete memory-admitted result before publication; payload credit stays with the query/result owner until release. Variable ordering uses available degree or label statistics, but cost-based admission and paired server qualification remain pending (#1442). See configuration.

Factorized DISTINCT, top-k and counts have no cost admission

PATINADB_FACTORIZE also gates the RFC 0042 B3–B5 factor consumers. It is on by default since 2026-10-17 (#1442); PATINADB_FACTORIZE=off restores the reference operators. The consumers have no cost admission: every proven shape engages, with or without the cost model. They cover only single fixed paths (no variable length or cyclic names) that split at a pivot into two independent branches:

  • ORDER BY … [SKIP] LIMIT whose sort keys each live on the pivot or on one branch;
  • RETURN DISTINCT (under ORDER BY) over the pivot plus one branch;
  • ungrouped count(*) with at most one cross-branch =/<> comparison.

Tied sort keys in the window, floating or container comparison values, memory refusal, and degenerate factors (for example a single hop from an anchored node) run the ordinary operator instead. Results are the same; the time spent before the fallback is not recovered. See query planning.

Independent path counting is narrowly admitted

PATINADB_FACTORIZE (on by default since 2026-10-17, #1442) adds grouped aggregation for a subset of independent fixed branches. With the default cost model only the per-pivot count path is cost-admitted, and only once the degree catalog has the groups; until then the flat aggregate runs. Branches that share a relationship type are admitted only when they reach the pivot through it in the same way (at most two hops deep), and then only for COUNT and COUNT(DISTINCT pivot). Floating/container grouping values and sums whose signed subtotal bounds can overflow retain reference execution. The general factor/Flatten framework is incomplete; this is not full RFC 0042 B1/B2 support. Memory refusal before output also retains reference execution. See query planning.

The agg.disjoint_path_count candidate avoids cross-product rows for eligible COUNT(*) paths. It does not implement general join aggregation, grouped counts, DISTINCT counts or shared-relationship-type branch factorization. Selection uses branch length rather than measured fanout. The original LSQB comparison at 928cd9ad completes Q1/Q6, but other query regressions remain; there is no overall performance qualification. See query planning.

The integrated development alternative may select a cheaper estimated entry inside the shorter independent factor. It retains one memory-admitted UUID/count table under the existing group cap; those extra bytes can change resource refusal. Missing statement scopes and sentinel/internal group bindings retain the original path. Errors encountered after execution begins are not retried on that path. Primary-only label counts and bounded cold probes are heuristics, particularly for secondary labels, and add planning cost. Its focused and restored tests, actual hook-bypass counterexample and lint checks passed, as did combined broad source validation. Native performance qualification remains pending.

Pure count factors may additionally retain one governed neighbor set for a fixed EXISTS endpoint (PR #1410). This adds memory beyond pair memoization and can change resource refusal. Reuse requires a completed bounded scan and live index views; overlays retain point probes. A scan can encounter malformed adjacency entries that individual point checks would not read. Source validation passes; original-query Q9 ABBA shows a benefit, while Q2 timing remains inconclusive. See the engineering LSQB ledger for measurement scope; this is no general performance guarantee.

Development join counts and APPLY streaming remain conditional

PR #1410 adds partial counting only for ungrouped COUNT(*) over proven vertex-keyed inner joins after ordinary join ordering. Scalar keys, optional matches, DISTINCT and intervening filters retain existing plans. Grouping changes retained memory and can change the point of resource refusal.

APPLY streams only under a whole-consumer snapshot-read proof. It still retains the outer input and one seed result, and it keeps cumulative output limits. Unproven effects preserve eager evaluation; a pure-read LIMIT can avoid errors in unconsumed later seeds. Correctness controls do not establish a native performance improvement. Original-query A/B measurement remains pending for this candidate; the previous Q5 regression is still open.

Buffered PROFILE memory parity: bounded validation completed

The earlier buffered Bolt PROFILE route did not carry the statement memory scope into its blocking worker. Its diagnostic output therefore cannot establish whether an ordinary read engaged a strategy that requires that scope. Instrumented PROFILE elapsed times are also not ordinary-query benchmark measurements.

The correction carries the existing scope into the read-fenced worker and retains result ownership through buffered delivery and cancellation. Memory refusal remains an error, with no unmetered retry. Its focused and restored controls, scope mutation, Clippy and formatting checks passed. The original validation run remains recorded as FAIL for an index-only ratchet issue; staging the six deleted files and new files made all 124 ratchets pass without changing Rust sources. Combined broad source validation passed; native performance validation remains pending.

The experimental early adjacency-recording gate was removed after the combined ordinary-binary comparison established no material Q2 benefit. This removes its additional doorkeeper lookup and cache-admission timing change; ordinary drain-time admission and generation checks remain. Historical validation and failed intermediate receipts are retained. The subsequent single-vertex UUID source-emission change passed focused/restored, mutation and combined broad source validation. Its ordinary-binary performance measurement remains pending.

Ordinary transient EcoVec rows used during traversal and predicate evaluation are not all charged to the statement memory governor. A row-construction counter is not an admission counter, and release of registered result owners does not prove that every transient allocation was covered by the query budget.

Transaction memory admission is in development

The combined server source wires RFC 0036 admission through Main with one cgroup-aware budget, shared work governor and inventoried native plan. Focused configuration/startup and combined source checks pass. ENV/CLI/YAML controls and read-only --check-config use the same resolver; PATINADB_CACHE_LIMIT=0 disables entry caches while preserving work admission and the native allowance. At 4bfb2d3e, the bounded Q/TX/foreground control and original 120-second default smoke pass with actual managed settings and five hard-2-GiB/no-swap nodes. The separate full pressure run passes the agreed runtime gate after independent and final review, with explicit cap overshoots and sampling gaps. Performance measurements are complete with regressions; performance acceptance remains open. See the configuration. Earlier source-qualified subsections describe their individual increments.

The later Bolt streaming-scope change has passed focused Core/operator/output controls and broad regression validation. Covered parameter and expression admission now follows the whole blocking read worker, including live and plain historical read fences. Context-held credits can retain dead intermediates until worker exit and conservatively reject a long stream before its exact live heap reaches the cap. Sort scratch and encoded frames keep their own lifetimes; frame delivery/discard can release bytes independently. Cancellation seals admission, but is not an immediate worker-join guarantee.

This scope does not cover every streaming allocation. L3/L4 RowTable hits still copy outer containers without a query owner; ordinary lazy hydrate_row still builds property containers and recursively hydrates scalar values outside that admission. The existing bounded L3 accumulator and native/cache/transport internals remain separate. The earlier 4bfb soak and prior-source performance numbers do not qualify this later change or establish the requested 10% overhead bound. See the current streaming scope.

The completed bounded comparison of that streaming scope at 08bd4971 still finds a first-read increase in every paired block; the requested maximum 10% overhead is not qualified. A separate same-binary cache-initialization control supports shifting work before the first query, while retaining the preparation cost. It uses a synthetic policy access and is not a product-performance result. The later product candidate prepares the existing empty policy sketch without recording any access or changing a setting. Disabled construction and later embedded activation remain lazy. Source/negative controls pass. The new candidate’s original query/retained measurements now complete with improved first-read medians and adverse individual blocks retained. A general 10% bound and final-source pressure qualification remain open. See the performance ledger.

The first managed-default smoke failed on temporary native-cache admission before mixed load. A correction to tag-export owner lifetimes passes source and bounded integration checks. The next managed-default smoke also fails: after two acknowledged seed batches, the third refuses on native capacity before mixed load. A follow-up counts retained tag-export bases, overlapping snapshot jobs and one foreground mirror inside the same native allowance, reducing automatic per-file caches. Its source, real-owner checks and subsequent default smoke pass. The separate full pressure run passes its agreed workload; performance acceptance remains open. A failed chunked request can retain earlier committed chunks and must not be replayed as a whole without reconciling its outcome. See the validation ledger.

A later bounded managed-default control completes its query-refusal and subsequent-progress checks, then fails because a correctly typed Bolt TX memory refusal lacks effect metadata. Its TX no-effect/progress checks and foreground case are not completed by that failed run. The correction at 4bfb2d3e passes source checks and a new actual Q/TX/foreground control: exact typed non-retryable refusal with no effect, preserved graph/HEAD/history, subsequent one-Engram progress, five-node readback and accounted-owner release. The separate default smoke passes all original faults/backend/UI oracles and seven service generations. Sampled OOM evidence is zero with explicit gaps; the small smoke shows no reclaim and is neither 10 GiB-size nor three-hour pressure evidence. Accounting, limits and execution behavior stay unchanged. Paired measurements of a15499a0 do not qualify the corrected candidate. See the boundary ledger.

The interim RFC0036 integration comparison uses small one-voter fixtures. It observes both median variability and higher tails in some cases; it is not a performance acceptance, safe-default or full-pressure qualification. The new 4bfb write matrix also finds higher unconstrained 1 KiB COMMIT p99 in all four paired blocks, both on/base and on/off; see the performance ledger. The completed 4bfb retained-tail comparison also finds higher first Bolt-read latency in every block, while its exact recovery/reopen checks pass. The complete three-hour five-node pressure run now completes 10800.334 mixed seconds after 36,880 x 64 KiB values and five allocated graph files above 10 GiB. Its final auditor verifies all three faults, eight in-mix Q/TX refusals and separate post-mix foreground refusal/release, positive timed reclaim and observed OOM0 across seven generations, retaining sampling gaps and transient values above configured MemoryMax. Final review accepts this scoped runtime qualification; there is no universal capacity/concurrency or performance PASS. The measured file growth does not establish long-term stability or compaction behavior. See the full outcome and limits.

The explicit governor’s normal-write gate checks already resolved request data against a checked 6 * counted_data_bytes + 64 Apply allowance before proposal. It includes conditional preimages and Anamnesis data and preserves existing TX/Engram boundaries. Earlier producer reservations remain necessary; the check is neither query-text prediction nor proof that state-dependent index expansion, native pages or every follower’s committed working set fits. No second Apply reservation is taken for the same data. A rejected chunk is not proposed; a chunked import retains its successful prefix and latches the first terminal proposal failure, including under ON ERROR CONTINUE. This preproposal check alone does not qualify the candidate defaults. See the scope and pending validation.

The explicit Rust native-cache API shares an aggregate capacity across actual persistent, temporary, checkpoint and simulated-disk opens. Application entry capacity is reserved in the same split. A protected native floor keeps temporary opens from consuming capacity intended for persistent graph/Raft/audit stores; both classes still obey the total. Physical replacement admits its new native cache before removing the old store and transfers that reservation into the replacement. Ordinary strict refusal preserves the old registry entry and staged file; retained readers keep the old generation charged. Explicit required replacement uses the smaller-grant policy described below with the same ownership ordering.

Startup also inventories existing eligible graph directories, missing reserved databases and the separate Raft/audit files before opening its first graph. It reserves and transfers one native lease per file. Capacity refusal precedes file initialization and returns the unused plan’s reservations. Later native/schema failures return unconsumed reservations, but transferred leases stay charged until their last native owner drops; independent snapshot Trees can outlive the outer open error. Earlier durable initialization can remain on disk. Main now selects the inventoried sizing API described below. Successful startup planning alone does not qualify replica Apply/recovery under memory caps. Cache capacities are not exact resident-memory limits; native scratch/backing and pinned pages remain separate.

Startup also transfers one reserved native cache slot for committed Fork/Squash reconstruction. Its native cache charge stays held until the last backend/read guard returns the slot; contending calls wait before history/retention/write locks. Native/Sim ownership and bounded committed-command controls pass. This does not change optional reconstruction or legacy constructors, activate defaults, solve permanent fork-target capacity or qualify all recovery. Whole-Apply cancellation ordering remains a separate obligation.

An explicit sizing API derives primary and bounded scratch requests from the same startup inventory and aggregate allowance after existing entry shares. It includes known graph/Raft/audit owners, one required reconstruction slot, retained tag-export bases, a D-mirror-plus-checkpoint snapshot wave, two shared periodic/tag jobs and one foreground mirror when otherwise free. These are finite sizing allowances, not reserved optional slots; other admitted queries can consume them. The updated formula’s source checks pass, followed by the bounded automatic-default smoke above. Arbitrary concurrent/retained generations, future database creation and other mandatory recovery owners are not qualified by that finite count. Exact REDB overrides, including zero’s 1 GiB native default, must fit or reject before installation. See the source validation and remaining scope.

The required-native increment with focused source validation allows explicit committed Create/Anamnesis/Fork targets and received snapshot/restore opens to receive a smaller configured cache, including zero, when the preferred request cannot fit. Required pending/forced repair mirrors and checkpoints likewise use only the remaining native and temporary allowance before the durable install intent clears. Ordinary opens and periodic snapshots stay strict; real storage errors and invalid accounting are not swallowed. A zero-byte lease still follows the last native owner, and old generations remain charged. Startup and exact ENV overrides stay strict: PATINADB_REDB_CACHE_LIMIT=0 continues to request 1 GiB.

Under automatic sizing a database created after startup gets no configured native page cache until the next restart (#1499): its grant may not consume the temporary inventory kept for snapshot and mirror work. Reads of such a database depend on the OS page cache; that performance cost is unmeasured. Restart, a dropped database or a smaller explicit PATINADB_REDB_CACHE_LIMIT makes capacity available.

This removes only the configured-cache obstruction on those required paths. Zero cache still allocates active/pinned/dirty pages and native metadata; in-memory repair backing is independent of the cache allowance. It is not a whole-memory or liveness guarantee. A remaining resource/storage failure during committed Apply still follows the existing failure path rather than silently skipping the entry; clients must not infer permission to replay writes. Full required-memory, pressure and performance qualification remain prerequisites for default activation. See scope and executed source validation.

The existing authenticated Admin metrics route now reports the explicitly installed work governor’s accounted pools. Disabled pool values are NaN, not observed zero. Foreground zero only describes reservations at that scrape; it does not prove all workers stopped, all allocations were admitted or RSS fell. See accounted work memory.

An explicit Linux monitor can reuse the existing process tick to fence new positive foreground and optional-background reservations after a lower effective ceiling is observed. It keeps the original startup baseline, retains existing owners and permits protected committed Apply/recovery. Unknown samples are not zero; an increase does not reopen admission. Main now selects this monitor from the same retained startup discovery and budget. Its configured two-second sampling interval is not a response-time or OOM-prevention guarantee. See the configuration.

The combined startup, metrics and monitor source checks pass, including four behavioral negatives and restored positives. A test-only native-lock witness strengthens durable-reopen checks without changing their shutdown deadline or retrying database open. It does not establish general recovery latency. No new full-pressure, default or performance qualification follows; see the increment scope.

The explicit query context covers retained table/key storage for wildcard and explicit-column projection DISTINCT. Known duplicate keys are not recopied. The explicit-column path also accounts retained result slots and still consumes all input before yielding output; duplicates still count toward its row cap. Slot credit follows the iterator through query sealing and early consumer drop. Ordinary plan UNION DISTINCT now shares the same collector while preserving arm-wise execution and its all-input row cap. Seeded/APPLY, memo row copies and both central read RETURN collectors now retain row-slot credit through their actual allocation lifetime. This is runtime allocation accounting, not query-text cost prediction. The central RowTable hydrator now admits its output containers, fresh property decoding, label arrays and scalar copies; its result owner keeps credit through client delivery and query sealing. Fresh scalar string copies can add cost, which is not yet measured. Original Row/EcoVec/value producers, Raw streaming and alternate result emitters, remaining operators, memo keys/table and top-level Cypher result merge still need coverage. Keyed and cartesian Join output now also reserve fresh row backing and recursive copied values before construction (#1468), and produce one combined row at a time. Credit remains with query/result delivery even if downstream operators have already discarded intermediate rows; this can conservatively refuse work before current live output reaches the cap. Probe key copies have a separate iterator-local owner (#1469), released when advancing or dropping the probe; they do not accumulate in delivery. Keyed build hash allocations, composite keys and bucket capacity are admitted under the shared table owner (#1470). Join input slot containers now retain their own permit through iteration or keyed build handoff (#1471). Sort input, correlated/rooted MATCH candidates and accumulators, and OPTIONAL input/extensions/results/memoized candidate buffers also retain row-slot credit (#1472). Cache copies count their overlapping capacity; residual filtering retains the original buffer’s capacity. Pre-existing row payload producers, optional memo keys/table storage and model clones still need coverage. Shared seed/import selection, APPLY/OPTIONAL merges, OPTIONAL NULL extension and selected comprehension merges now admit copied payloads (#1473). Unique owned input entries move; shared entries reserve their copy. Credit remains with query delivery even after a temporary row is discarded, so this can conservatively refuse before the live output alone reaches its cap. Within one pattern comprehension, unchanged outer bindings can now remain in one admitted row while unique local rows with the same binding layout are moved into it (#1475). Shared rows or changed layouts use the existing copy checks. The first imported payload still requires admission; this adds no cache or setting. Property reads on an Entity binding use its identity without copying unrelated hydrated properties (#1477). The existing storage lookup still determines the value; cached hydration does not override the current transaction’s value. Selected-value decoding and other uncovered producers retain their existing limitations. This change adds no setting or measured latency guarantee. UNWIND now admits selected row backing and recursive carried-value copies before appending a binding (#1478), including Retain-fused and seeded execution. Ordinary and fused outputs stream one row at a time; seeded execution retains its existing bounded collection. Copy credit stays with the enclosing query/result owner, conservatively including discarded intermediates until that owner drops. The development follow-up also admits UNWIND source-list copies and containers; source failures return before output or a FOREACH body. Non-fused Retain admits kept copies of shared rows and shrinks unique rows in place, preserving their existing backing capacity and producer credit. Refusal, result ownership and caller error propagation have regression coverage. Native property decoding and other producers remain separate gaps. No throughput or pressure-soak qualification follows from these correctness tests. The FOREACH follow-up applies the same carried-value checks to the initial body row and reserves its container through the existing Rows owner. Refusal, shadowing, entity provenance, credit lifetime and atomic rollback/reopen have regression coverage. CREATE/MERGE binding containers are covered by the additional admission described below. Scalar map-field reads in CREATE/MERGE use the existing borrowed evaluator, copying only the selected value with query admission. Function and compound property expressions now also admit temporary binding-row containers and their recursive value copies before evaluation (#1478). Copy credit follows query result delivery, including discarded intermediates. Refusal, scalar-result correctness, credit lifetime and atomic rollback/reopen have regression coverage. MERGE now borrows seed rows, admits match/output row-vector storage, and admits carried seed copies before ON MATCH. Output-vector credit follows query delivery; intermediate credits are conservatively held until delivery. Refusal, ON MATCH semantics and atomic rollback/reopen have regression coverage (#1478). CREATE/MERGE binding maps now reserve a conservative backing allowance before allocation and admit recursive seed copies. Created-row backing and mutation Apply/Union/read collectors also use admission; FOREACH CREATE shares these paths. The map allowance is checked against allocator requests on the supported toolchain. Intermediate credit is conservatively retained through query delivery, so a query can be refused before its actual live heap reaches the configured cap. Native property decoding, other model copies and OPTIONAL memo storage still have separate coverage gaps. Source regression and rollback checks pass (#1478); throughput and the final five-node memory-pressure soak remain unqualified. This adds no runtime setting or complete query-RAM guarantee.

The development query context now also admits full-sort key-slot containers, index permutations and visited markers before allocating them. A scratch refusal propagates as a resource error before row reordering. Stored vertex/edge property keys also decode under the sort owner’s allowance, with admitted lookup scratch released per batch. Property-cache clones are bypassed for fresh decode; the performance impact is still unmeasured. Existing rows, expression/scalar key producers, lowering, native/registry buffers, top-K and aggregation still need coverage. This development path adds no setting or complete query-RAM guarantee.

With the explicit development governor, both expression evaluators now share admission for argument arrays, list/map expression containers, comprehension output and generated integer lists. The existing buffer budget checks actual requested capacity before allocation and old/new overlap before growth. A typed resource failure stops the admitted producer and reaches the normal query error boundary; no write is proposed by a failed private resolve. Argument slots release after the call. Returned container credit stays with the query/result owner, conservatively including dead intermediates until that owner drops. This can reject work sooner than exact live-heap accounting. Strings, recursive value clones, entity/row payloads and remaining producer paths need separate admission; these container credits do not establish complete coverage or an RSS bound. Existing row caps, semantics and error types remain. No new setting/default is activated.

The development governor now also applies one coarse allowance before scalar string production: 6 * work_bytes + 64, with checked arithmetic. This is a counting policy, not six times measured RSS. trim variants, string reverse, left, and each split piece use actual UTF-8 input bytes. ASCII case conversion uses the same byte count; non-ASCII lower/upper conversion uses three times input bytes to allow expansion. replace counts actual non-overlapping matches (also empty patterns) and uses the larger of input and output bytes. substring and right use four times input bytes to include their existing char-array scratch. A 500-byte ordinary string therefore reserves 3064 credits, without allocating 3064 bytes merely for accounting. Split also admits its growing list slots.

The shared arithmetic executor also applies this allowance to + string concatenation, using the combined UTF-8 byte lengths of the actual values. A mixed i64/f64/bool operand adds 512 work bytes for the existing scalar formatter before the sixfold allowance. That fixed allowance is a policy, not its measured RSS. The same path serves both expression evaluators. List concatenation admits its growing output slots and moves existing elements into them, preserving nested payload storage instead of recursively cloning it. Existing source buffers still need their own admission and remain separate from the new output slots. The query/result owner retains output credit. Consumed string operands release their own credits only after new output construction finishes; operands whose data may be moved into results retain their owners. Other producers can still retain dead intermediates conservatively. No parsing-based cost prediction, default or ENV switch is added. See concatenation validation.

Covered literal, parameter and scalar-binding copies in the expression executors now share runtime admission. Reading an existing string shares its EcoString backing (or copies its inline bytes) without a new expression payload charge (#1449). Source storage remains a separate obligation; newly computed strings still require producer credit. Recursive values reuse the checked container/payload copier, including map keys. Materialized table results keep fresh admitted payload copies so their delivery owner is independent of source rows. Inline strings allocate no heap payload, so the copier checks scope admission with zero payload credit; their bytes are covered by value/container storage. Heap copies reserve their fresh payload layout. String functions retain their existing producer allowance. The scalar dispatcher borrows its arguments and admits selected/copied output instead of eagerly cloning its first argument. Consumed list/map indexing moves the selected payload. Output credits remain with the query/result owner, conservatively including dead values. This is scoped coverage; other row/AST/value copies and producers remain open. See runtime value-copy validation.

The augmented runtime rows used by both expression evaluators for local bindings now reserve their actual container capacity and copied outer payloads before allocation. Mutating shared row backing first admits a fresh copy through the same helper. Lexical shadowing and evaluation order remain unchanged. Source storage and inserted values still require producer ownership; copied row credit stays with the enclosing query/result owner, conservatively including dead values. Other row producers remain uncovered. This adds no keyword-based prediction or configuration switch. See runtime binding-row validation.

The library transformations and Unicode behavior remain intact. Credit is reserved before the call and retained with the query/result owner; dead temporary strings can stay charged until that owner drops. These estimates may reject work before its exact live heap reaches the limit. Existing value ownership, other conversions, remaining recursive clones, native memory and remaining producer paths are separate. All-scalar Unicode and real allocation-request probes support the policy for the tested toolchain and cases; allocator slack/RSS are not proven by those probes. Performance impact is unmeasured. No new knob/default is enabled.

RFC 0036 does not yet enforce complete transaction/query memory coverage across server requests. Request clones retain an attached reservation until the last data owner drops, but legacy producers and decoding still require allocation coverage. The admitted buffer primitive also covers old/new container overlap and its transfer to the shared operation owner. Completed-statement buffers can retain their TX charge without freeing process credit, while sealed query aliases cannot allocate again. Explicit Core resolve methods now admit new journal payloads and collector storage on a disposable mirror and preserve that owner on return. Oversize/overlap refusal leaves the tested live graph and HEAD unchanged, including after reopen. The five Core SI resolve paths also expose admitted capture, including both retained transaction mirrors. Capture refusal poisons a retained mirror so subsequent calls cannot read partial private writes. The returned operations keep their query charge and do not close the query’s result-delivery allowance. A Core segmented TX owner can retain those statement buffers while queries remain open; its directory and final container overlap are admitted before allocation. Only completed, explicitly sealed statements can freeze into a shared batch. Whole-TX preflight still precedes any staging write across segment boundaries. An explicit Rust Limits.memory_governor now enables admitted operation capture/retention in Bolt explicit transactions. Its statement owner moves through the blocking worker and pending result, closing admission after delivery/discard or transaction termination while live operation owners retain their charge. Owned RECORD output now has pre-allocation admission: encoding borrows Core values, uses an admitted iterative stack and reserves encoded/framed overlap plus its shared owner. PULL shares these bytes without deep-copying the record. Memory refusal aborts the unproposed transaction, including prior buffered statements. This field was opt-in at that source-validation revision; the combined Main candidate now supplies it. The assembled Bolt wire message and decoded PackStream layouts now retain admission through their actual data owners. Input and admitted RUN results share one query allowance. Incomplete-frame refusal closes the connection after aborting unproposed work; complete-frame decoder refusal retains the failed-state/RESET behavior. Control messages still share foreground capacity and may themselves be refused. RUN parameter conversion reserves maps and fresh values for explicit transactions and autocommit before construction; the admitted private Core resolve APIs also reserve runtime bindings. Workers share the binding owner; returned parameter aliases keep its credit until their enclosing result is dropped. The result wrapper does not admit its own row storage or independent clones made by its caller. Explicit admitted Bolt COMMIT also reserves its new CAS token containers, fresh values, label arrays and sorting scratch; local request/entry clones share those data owners. Old vertex/edge properties for the final CAS tokens are now decoded directly into admitted storage. Their strings, containers and iterative decoder scratch are reserved before allocation; read/decoder errors propagate instead of becoming missing properties. Refusal occurs before proposal and reports no effect. Constraint catalog construction, native page/cache/registry storage, encoded overlay read buffers, the interned label arena, fallback preimages and other write paths remain outside coverage. A large old property can therefore make COMMIT refuse even when the small update itself fitted RUN. That increment kept the wire schema and defaults unchanged; full qualification is still pending.

The common column-oriented result hydrator now admits a coarse container allowance based on actual produced cells plus separately admitted property/value payloads, retaining output credit through destruction. The explicit development governor now also connects ordinary REST/Core data reads to the same execution scope. L3/L4 QueryResult copies admit their containers and fresh payload backing, retaining credit after cache eviction. Native mutation fences protect live and plain historical execution stores, independently of effect classification; private lazy reconstruction overlays remain writable. Original row/value producers, persistent-cache/L4-decode buffers and alternate result emitters remain uncovered. This does not activate production defaults or prove complete coverage. The 4096-byte per-cell counting policy may overcharge repeated columns and released scratch; it is neither an RSS figure nor an allocation of 4096 bytes per cell.

With the explicit development governor, ordinary REST /cypher autocommit writes also use the shared Core capture and pinned CAS/OCC budgets. Oversized execution or old-value CAS preparation refuses before proposal with HTTP 400, Neo.ClientError.Statement.ResourceExhausted, retryable: false and patinadb_effect: "none". Owners retain their credits through workers and proposal. Ordinary REST reads and writes now also admit their converted parameter values/containers/keys and worker query copy, keeping their shared owner charged while any worker uses it. Write input and execution use one query allowance. The /cypher wire collector also reserves capacity before growth, including chunked bodies, and hands that query scope to execution. Axum’s existing HTTP body limit and JSON errors remain; wire refusal has the same no-effect resource response. A conservative byte/structure/escape allowance now precedes the original JSON decoder and remains with its request; the policy and overcharge are described in Configuration. Ordinary data-read response byte buffers now admit growth and retain their capacity credits through HTTP delivery and final transport aliases. Refusal discards partial output before success is sent. Ordinary governed data reads now use the fenced Core execution context and admitted QueryResult materialization described above. Remaining value producers, JSON-tree construction and completed writes/control replies retain uncovered paths. Transport frames, allocator overhead, rendered error bodies, downstream provenance copies and other REST routes remain uncovered. This does not activate production defaults. Full RFC pressure/performance qualification remains pending.

With the development governor, both Bolt autocommit modes use the existing runtime capture and RECORD budgets. Oversized captured writes or encoded results fail before proposal with patinadb_effect=none; a result refusal cannot leave that write committed. Buffered results retain their charge through partial PULL, and RESET/disconnect releases their actual owners. RESET after a successful autocommit discards delivery only; it does not undo the committed write. Pipelined execution retains the same query allowance across OCC retries and drops failed-attempt capture/result buffers before re-resolving. Its operation owner survives the conflict-check worker and proposal. Cancellation seals the query; any remaining worker data keeps its charge until actually dropped. Converted autocommit parameters share their existing immutable owner across workers and OCC attempts. Expansion refusal precedes execution/proposal and reports no effect. Streaming reads keep admitted input on their blocking worker; the pending statement closes admission on delivery/RESET/disconnect, while live worker data remains charged until dropped. Streamed RECORDs now use the same admitted encoder: queued and in-flight frames retain their byte credits until sent or discarded. A mid-stream refusal returns FAILURE after any valid prefix, never a successful truncated result/bookmark. In an admitted explicit transaction, a failed historical stream also aborts its unproposed buffered writes. RESET can then accept a small read. Idle consumers can exhaust the byte budget before the 256-row channel fills. Core executor copies/rows, result metadata, fixed channel storage and native transport/cache buffers remain outside this accounting. Both autocommit write modes also use the admitted final CAS capture described above, from the same snapshot as resolve. Old-property decode or token-budget refusal propagates before proposal; admitted callers cannot fall back to unmetered live capture. Operation and CAS owners remain charged through retries and proposal clones. Earlier constraint catalog allocations, provenance and other clients still need integration. This does not change the pipeline default or activate the proposed memory settings. With the development governor, OCC now uses admitted hash sets for entity, property and replaced-label identities. This covers the candidate, each intervening Engram’s structural marks, and pinned CAS construction in both Bolt write modes. A pipelined candidate keeps its table credits while its in-flight registry entry or a conflict-check worker still owns it. Exhaustion is a pre-proposal resource error with no effect, not a conflict retry or optional-CAS fallback. A small write can therefore refuse while checking a large unrelated commit since BEGIN; retrying a new transaction after RESET starts from a fresh base. Retained constraint maps, label arrays, copied values, prefilter sets and additive UNIQUE tuples now use that allowance too. They keep their credits across history pages; replacing a map value releases its old payload only after admitting the new one. This keeps the existing final-value and additive-UNIQUE semantics. Emitted keys and their scratch tables/values are now admitted too; their owners keep credit after the accumulator is dropped, including while waiting in the pipeline. Composite-key variants are emitted individually with the existing expansion cap and numeric equality rules. Governed store-property reads for these checks now decode under the same allowance, retaining the source credit through key construction. Missing properties remain distinct from memory/read errors. Buffered final values are borrowed; explicit deletes do not read the live value again. Live and pinned read views retain their original boundaries. Decoded label IDs and label-result arrays in OCC and pinned CAS capture now reserve before allocation too. Missing vertices are distinct from read or memory failures. CAS retains the same admitted array after removing its primary label; membership/order semantics are unchanged. The retained list of intervening Engram IDs now admits capacity growth before allocation too, including temporary old/new-buffer overlap. Metadata strings are borrowed during the walk. Many small foreign commits can therefore exhaust the allowance even if each individual body is small; this returns a resource error before proposal, not a conflict retry. Governed OCC now also decodes each stored body directly into admitted operation/value storage. RAW bytes and metadata strings are borrowed; decompressed ZSTD output remains charged while decoding. Each body owner drops after its fold; the shared constraint context survives across bodies. A refusal remains a resource error before proposal. The native ZSTD context/window, tree/overlay/cache buffers, interned label arena and catalog source data still need separate admission. No additional toggle or automatic default is activated. These development controls are not a full memory-pressure qualification or a measurement of the PR’s performance impact. Handshake/dispatch, provenance metadata, streaming parameter copies, per-evaluation value copies, mirror/index storage, Core result tables and operator state, result metadata, TLS/WebSocket internal buffers and native scratch remain uncovered; charging a decoded input does not charge its later copies. These partial Core entry points reject unsupported DDL/chunked commits before execution. They use fresh string backing and add copy/CPU work; an admitted allowance does not promise success from the actual allocator. Legacy APIs stay unmetered. Existing operation-count, wire-size and per-file cache limits still have distinct scopes. Entry sizing counts actual Postcard/JSON bytes without retaining complete encoded bodies; it does not bound the already constructed request or later transport buffers. An explicit development governor also admits outbound AppendEntries/Vote byte chunks through the shared protected Apply pool. It preserves the JSON/Postcard payloads and existing RPC deadlines. Credits follow queued/in-flight byte owners; serializer failure is a transport error, not successful truncated EOF. A full foreground pool does not consume this reserve. This does not account source-entry decoding, responses, native HTTP/TLS buffers, snapshot traffic or all committed apply/replay memory. Protected-pool competition can delay replication through transport retries; no full progress/envelope guarantee follows from this adapter. Explicitly governed append receivers also reserve their contiguous wire buffers before growth. The original actual-byte cap still applies to chunked bodies. A buffer refusal is a retryable peer 503 before Raft handoff, not a local apply rejection or a client no-effect promise. Vote ingress is unchanged; native HTTP frames and decoded entries remain outside this buffer counter. The full supported entry/progress envelope is still required before activation. The separate operation decoder now admits OperationBatch arrays and recursively owned values as they are constructed, retaining credits through the last batch clone. It uses the same peer503 refusal before Raft handoff. Entry directories, metadata/CAS fields, interned labels, parser scratch and native buffers remain uncovered. These operation credits do not cover committed apply/replay decoding, and an arbitrary stored entry is not yet proven to fit the Apply pool. Retry is not a substitute for that proof. The development governor also limits encoded replication log-read batches before decoding the entire requested backlog. Each entry stays intact, including a first entry larger than the target. This does not reserve decoded heap or constrain the sum of concurrent readers, and complete apply/replay reads remain uncovered. patinadb_raft_replication_read_limited_total counts batches stopped early at this target; it is not a memory-byte or rejection counter. Legacy reads are unchanged. Current-format forced-install boot recovery verifies its full retained tail before restore, then replays entries individually with the original group durability barrier. A temporary hash file detects source changes before each entry is applied; the durable tail format is unchanged. This removes whole-tail decoded retention from that path, not an individual entry’s memory requirement. Live forced installs also copy the native log range one entry at a time to the same durable tail before restore, then use this replay path. Their pinned native read snapshot and copy/verification I/O still need qualification. Legacy v1 boot recovery now uses an anonymous copied tail and the same verifier, leaving its descriptor unchanged; another boot still requires the original live log. This increment is validated in its scoped controls. It adds temporary disk allocation and I/O, including sync, which need complete recovery-to-ready measurement. A bounded runtime Apply diagnostic confirms another open protected-path gap: OpenRaft’s replication entry cap does not bound full committed-range decoding or multiple decoded batches queued behind a held consumer. After release all checked values apply correctly; this is a memory-scheduling gap, not observed data loss. The diagnosis alone did not qualify a scheduler fix or default activation. See Apply queue diagnosis.

The bounded Apply integration now selects the reviewed OpenRaft source. With the explicit development governor, the serial worker loads at most 64 whole entries and respects the existing encoded-byte reader target; an oversized first stored entry still progresses intact. Pending ranges coalesce without crossing snapshot commands, and source logs survive until durable Apply/install permits purge. These are scheduling bounds, not an individual entry’s decoded-memory or native-page bound. Focused integration and Raft/Sim/crash controls pass. The subsequent Bolt boundary correction resolves the earlier fixture failures with fixed test allowances and additional small-cap refusal controls. Complete resource qualification, defaults and the new pressure soak remain open. No ENV toggle is added by this integration.

Raft log append now serializes entries one at a time inside the same atomic native write transaction. Each encoded buffer is released before generating the next; success is acknowledged only after the complete durable commit. Generation or storage failure cannot publish a partial generated batch. This removes the whole-batch encoded staging buffer; input entries, individual encoded values, native transaction pages and complete protected Apply ownership remain separate obligations. No new setting or default is enabled. Runtime cost still needs measurement. See atomic append validation.

Ordinary boot under the explicit development governor now preflights each original 64-entry batch and streams its entries through the unchanged apply body/barriers before Raft construction. It retains no whole decoded batch or unused response vector. This adds a second decode pass; its startup impact is unmeasured. The fault-injection build retains its existing asynchronous seam. Individual-entry expansion, non-Apply complete log readers and complete protected replay capacity remain open; final performance is not qualified. The ordinary /cypher request decoder uses a conservative pre-parse allowance under the explicit development governor. Its bytes/structure/escape policy can reject requests earlier than their final retained size alone would require; quoted punctuation and ignored fields are included. Credit follows the decoded request, but it is an estimate rather than allocator/RSS telemetry. Native frames, error response rendering and subsequent ungoverned copies still need coverage. The proposed new memory settings are not server configuration options yet. Full input, execution, commit/result and native-cache accounting, protected replay capacity and pressure qualification are required before claiming a managed node envelope.

Mixed workloads under severe RAM pressure

Snapshot restore buffering and disposable native caches are bounded separately; neither is a total-process RAM guarantee. Native Raft log I/O runs on blocking workers, but foreground commits still wait for durable storage. Concurrent snapshot/history work can compete for storage bandwidth and exceed request deadlines even without an OOM. The original five-node, three-hour mixed-workload profile passed with at least 10 GiB allocated graph files and hard 2 GiB memory per node, including the original faults and OOM checks (#1409). It used configured background pacing, synthetic data and explicit backend reconnect after leader failure. It is not a general latency, 24-hour or transparent-failover guarantee. Timeouts and durability requirements remain unchanged. Raft log purge durably records a monotonic logical floor before acknowledging completion. Readers exclude that prefix even if physical records remain after a crash. Native cleanup resumes on startup reads and reclaims at most64 integer positions per durable transaction, sharing the ordered write lane with new appends and votes. It stops between transactions on shutdown; cleanup errors remain visible to subsequent storage operations. Server shutdown joins the current cleanup transaction; embedded callers must stop reclamation before immediately reopening the same file in the same process. Truncate still completes its bounded physical deletion before returning. These integer windows avoid a full key pre-scan, but the operation ceilings do not bound native I/O latency or dirty pages. Snapshot and log-cleanup contention remains workload-dependent. Physical checkpoint copying and package writes observe process shutdown and abandon the incomplete candidate. Private native checkpoint destinations and historical tag-export scratch files also check shutdown inside redb’s file I/O, including commits; cancellation fails and discards the private artifact. Live databases and ordinary restore retain their normal durable I/O. Successful snapshot publication still requires the original sync barriers. These checks do not preempt an already-running filesystem call or promise a fixed shutdown latency under storage pressure. Optional checkpoint copy pacing limits copied input bytes, not physical I/O or historical-tag work. It trades checkpoint completion time for lower copy pressure; the successful configured profile does not establish a foreground-latency guarantee. Historical tag-base fill pacing separately limits decoded direct-fill inputs. Other tag phases and native disk amplification remain outside the rate, so neither control bounds total I/O. Checkpoint package pacing separately bounds the byte-copy phase of completed native files. Final durability barriers and other work remain outside it.

The additional private snapshot native-write budget shares admission across checkpoint and tag-export files and includes native page rewrites. This closes a scope gap in input-only pacing; it does not bound physical filesystem traffic. The combined profile passed source-bound qualification; other rates and workloads remain unqualified.

Periodic asynchronous history snapshots use a shared decoded-record scan budget. Pacing occurs before the next record pull, not merely on compressed output. Foreground exports and explicit tag jobs retain their policy. This can prolong read-snapshot pins and copy-on-write disk retention; it is not a physical I/O limit or proof of foreground availability for arbitrary workloads.

Startup now prepares each opened graph with an empty durable commit before Raft and boot snapshot views start. This can process native freed-page work before the first replicated write, but shifts that I/O into startup and does not give a fixed startup or request-latency bound. Errors prevent admission. The completed three-hour profile retained the original memory, deadline and failure-injection conditions. Its physical file growth required separate review: owned-copy allocation tests demonstrated reusable capacity, not a bound on long-term growth. See disk reclamation.

Property layout boundary (#1399)

Schema 7 rejects older schema-6 stores before interpreting their property bytes; recreate/reimport data for this format change. The inline/external split uses a fixed 256-byte serialized-value threshold and does not intern values across owners or remove complete secondary-index keys. A query that needs wide values still reads those values. Warm hydration/point and write costs can differ, so this is not a universal latency improvement or a total-RAM guarantee. Existing query operators remain; there is no automatically selected property-column scan. See property storage for EXPLAIN/PROFILE and snapshot semantics.

Primary-scan file advice (#1399)

The Linux policy requires allocated database size greater than detected RAM at open. It is not dynamically reselected after growth or memory-limit changes. Repeated scans can benefit from reduced read-ahead and cache displacement, but the measured cold first pass can be slower. The database-size rule does not predict a query’s working set. PATINADB_PRIMARY_POINT_IO=off retains native I/O after reopening for workload-specific comparison. PROFILE’s query-thread backend-request bytes are not physical storage traffic.

Wide value-index storage (#1399)

Secondary payloads are empty, but full encoded values remain in ordered leaf keys. Native redb branch-separator shortening cannot generally remove long shared prefixes or repeated leaf values; this is not string interning. A more compact index layout can also increase physical I/O for wide primary scans under tight memory. The final prefix/multimap/dictionary controls also found selective-read and scan counterexamples; no new compact-key mode was enabled. Full-key storage remains the supported representation. See wide value-index keys.

Filtered-read preparation (#1399)

Residual string candidates and supported COUNT group values use bounded advisory preparation. Value-sorted prefixes are not representative samples: match-count extrapolation requires a uniform encoded value column and already cached population. Unknown or stale metadata may retain a less efficient path; runtime limits remain authoritative. This is not a general seek-versus-scan optimizer, a physical I/O/RSS guarantee, or a change to wide-value index layout. See filtered preparation for EXPLAIN/PROFILE scope and the conservative thresholds.

Covering reads under memory pressure (#1399)

The integer-property covering input is automatically selected within conservative admission guards. It supports eligible single-label aggregates, restores UUID encounter order and keeps the existing evaluator. Selective patterns, wide/mixed/float values, secondary labels, pending indexes and unproved shapes retain their existing access. This does not make every read index-only or choose an optimal plan for every data distribution.

Its run buffer, merge fan-in and logical scratch are bounded; existing aggregate group/DISTINCT state, file cache, backend cache and concurrent queries are separate. A scratch budget is not a total RAM limit. Estimated over-budget work declines before preparation; underestimated scratch and scratch-preparation I/O failures fall back before rows are emitted. Index errors, cancellation and errors after covering execution begins still propagate. Parallel-worker configuration retains existing aggregate admission; the policy is not a calibrated comparison of all paths. Synthetic qualification demonstrates a narrow-property benefit, not universal non-regression, customer or TB-scale qualification. See Query Planning and Configuration for the diagnostic off switch and EXPLAIN/observed PROFILE diagnostics. Alternate long-value layouts remain investigation only.

Offline restore followed by cluster expansion

Older servers can let a new replica reach the causal log index without the offline-restored graph baseline (#1325). The development correction requires single-voter bootstrap and durable snapshot/prefix retirement before expansion. Same-host SATA recovery passed at 64/4,096 tickets; independent-host and 1M multi-node recovery remain unqualified. Old bookmarks and write-token receipts do not survive portable recovery. See offline restore.

Bulk constraints and grouped ordering: post-0.21.0 correction

Released0.21.0 bulk-upsert resolvers can bypass schema constraints (#1320). The development correction shares Cypher’s constraint validation and keeps graph-chunk entities complete. Until using that correction, do not treat bulk request success as proof of constraint validity; ordinary Cypher writes retain their separate enforcement. See bulk upsert for atomic, chunked and ambiguous-response recovery boundaries.

Grouped queries that rename a field but order by its original expression can return encounter order instead of sorted groups (#1321), affecting pagination. The follow-up rebinds those keys in RETURN/WITH; on the affected revision, ordering by the projected grouping alias avoids the reproduced shape.

The combined development candidate passes a two-hour five-process workflow test with4096 generated tickets,17266 correct reads and359 measured batch/fence operations (#1261). Learner restart, leader replacement and lost-acknowledgement reconciliation pass;16 connection failures occur only during injected node outages. This is small-workflow evidence, not large-ingest or physical-WAN qualification. See repository docs/batch-sync-qualification-1261.md. Neither a completed test nor this documentation publishes the code correction into the immutable0.21.0 release.

Filtered aggregation and ordered pages

The development follow-up after 0.20.0 evaluates eligible single-string entry predicates and bounded string count(*) group keys from existing property indexes. Unsupported types, secondary-label scans, entity consumers and preparation-budget overflow use ordinary execution. Long ordered-edge scans can prepare bounded predicate membership and adjacent-hop target candidates; survivors still undergo ordinary matching, validation and ordering. A small result limit does not generally bound work when most candidates fail a filter.

Parallel aggregates retain the filtered entry, pinned parameters and covered input within each UUID range. Overestimated anchor counts can be repartitioned against the counted pinned label. Query threads still default to one; the qualification explicitly configures two, not an automatic or globally bounded two-worker admission policy. No request deadline or access filter is relaxed.

The final candidate passes twelve isolated cold starts and twenty-four correct responses, followed by a strict six-hour mixed graph/search workload with 113,327 successful timed HTTP attempts (#1261). HBASE author counts take 4.38–5.27 seconds across four isolated cold observations. Mixed author-comment pages still reach 5.42 seconds, and author-count p99 is 2.69 seconds across all projects. This is an availability/correctness qualification of the recorded workload, not uniformly subsecond analytics or an idle-relative latency SLO.

The fixture has 334,770 source tickets, three voters and two delayed nonvoting readers on one SSD host; this is not 1M/60M, real WAN, crash-injection or hard RAM-limit qualification. Evidence, artifacts, resource reserves and remaining risks are in repository docs/cold-query-qualification-1261.md; prior failed and interrupted candidates remain in docs/failed-jira-queries-1261.md.

These changes are not included in the immutable 0.20.0 release. Its earlier pagination soak remains separate evidence. Review and publication of this development increment do not retroactively change that released artifact.

Historical workload qualification

The local 1M-ticket history/maintenance qualification on 2026-09-13 was incomplete (#1261). Its run7 fixes passed the strengthened AS OF matrix, including actual historical comment bodies and row multiplicities: cold Q9 is 23.9–24.4 seconds, with mixed p90 at most 1.75 ms. Cold means a restarted process, not a flushed OS cache. All warm/mixed cells meet the 300-ms p90 gate. Tag snapshot jobs become ready under load. The measured legacy squash timed out at the operator HTTP client; that run did not qualify post-squash PITR export/restore. A request-only background wrapper would not remove the large reconstruction from replicated apply and is not implemented as a substitute. Cooperative query deadlines are not hard real-time I/O interruption guarantees. These historical failures are not evidence of backup corruption; later archive qualification does not turn the synchronous legacy endpoint into an async job. Full results and conditions: repository docs/history-read-maintenance-1261-2026-09-13.md.

The subsequent 2026-09-14 async-squash/backup drill (#1261) completed the squash and its history checks, but JSON PITR export exceeded its configured 32-GiB budget; restore was not reached. The new streaming archive and offline admin tool avoid a default total-archive cap and HTTP restore timeout. The subsequent local 1M archive/admin qualification passes, including the September16 repeat described below. Chunking does not guarantee constant total restore RAM: index rebuilding and existing graph-install bookkeeping still need memory and disk headroom.

The September16 patched-candidate repeat passes local HEAD/PITR archive restore after async squash under load (#1261). It verifies current graph/indexes, pinned historical values and refusal/non-publication for corrupted, truncated, over-budget and interrupted restores. It covers two pinned tags and64 update commits, not months of history or an independent backup host. All4,800 mixed reads succeed, with maximum query p90 at177.397ms; the old3× relative policy still warns for nine queries. The approved absolute300ms policy passes.

The repeated historical matrix passes every warm/mixed cell (maximum mixed p90 at220.765ms), but process-cold reads still take11.139–46.334 seconds; comment reads take22.398–22.478 seconds. This is not an interactive cold-history latency guarantee, and no OS cache flush was used. Keep appropriate client timeouts and qualify the actual customer’s first-touch history workflow. Conditions and source-bound evidence: repository docs/pilot-qualification-1261-2026-09-16.md. This locally tested patch still requires its normal PR/CI; the report is not general production authorization.

Learner onboarding

Portable archive version 1 omitted single-property index registrations and named index mappings (#1327). Compound definitions already travel in graph records. The corrected version 2 includes the missing metadata; older archives still restore, but require those declarations to be reapplied from schema migrations. See archive compatibility. The fresh 1M multi-node SATA recovery qualification is not complete: its schema oracle exposed this defect.

Explicit historical diff jobs avoid holding an HTTP request open, but are node-local and still materialize comparison state in RAM. They have admission, accounting, execution and result-size limits; queued/running work fails on restart. This is not a generic asynchronous Cypher queue.

Raft-server tag snapshots now run through the maintenance queue. CREATE TAG acknowledges the durable tag/pin, not snapshot completion. Waiting tag jobs do not retain native read versions (#1485); active snapshot builds and queries still can delay page reuse. Historical preparation can require replay. The post-fix five-hour pressure qualification remains open. SHOW JOBS is local and admin-only, without YIELD/WHERE or a cluster-wide aggregate. Asynchronous backup jobs are node-local full-registry jobs, not replicated or automatically failed over; interrupted running jobs fail on restart. Experimental replicated squash jobs are available through an explicitly enabled REST endpoint; legacy CALL patinadb.squash and embedded synchronous tagging remain synchronous. The async endpoint handles one bounded prefix, not an unlimited retention target. Its local 1M post-squash archive/PITR gate passes; larger histories and independent-host restore remain unqualified.

The recorded 0.15.0 local 1M-ticket simultaneous two-learner test failed its original relative gate (#1261), despite physical installs completing. The subsequently approved absolute 300 ms p90 policy does not rewrite that artifact. See release notes for values, policy and scope. A manual version is not production qualification.

Physical checkpoints include state and indexes, not the sender’s full history. The learner can catch up its current state while RFC-0025 history backfill and local anchor preparation continue. Current-state readiness is not full historical readiness or permission to become a leader. Interrupted installation can resume; completed checkpoint artifacts and pending anchor sources survive restart. For physical v5, a durable install intent replays a crash-interrupted database replacement before Raft starts. An unrecoverable intent fails boot rather than exposing partially replaced graphs; these guarantees do not extend the older logical formats’ crash contract. Forced graph repair may require rebuilding a newer cached checkpoint whose body predates the repair. The post-0.13.0 audit update reconciles receiver-only databases during physical installation: a database dropped before the snapshot cut must not survive on a returning learner. That reconciliation is part of durable install-intent replay (#1271). History backfill uses the same configured private-CA trust as Raft transport (#1273); this does not disable certificate checks or remove the separate snapshot-derived historical-base limitation. Budget disk for the wire envelope, staged database and background history work. The large local fixture measured 180.513 wire MB/s for a prepared checkpoint join, but only 48.855 allocated-source MB/s including checkpoint creation (#1251, 2026-09-06, loopback on one ext4 NVMe host, 60,030,350 vertices). Thus cold onboarding >=100 MB/s remains unmet. This is not measured WAN throughput.

The separate 1M-ticket logged-fixture SIGKILL drill passes retained-log and purged-snapshot plus newer-log recovery (#1261); it does not supersede the larger-fixture cold-onboarding limit above. During recovery, causal reads may return BookmarkTimeout, and snapshot installation can require a fresh authenticated Bolt connection. See First Customer. History status alone does not prove correct full-scale historical query results. Raw unlogged imports require an explicit history base; the current delta-only backfill does not transport snapshot-derived bases. Actual backend integration, WAN faults, off-host restore and larger-scale qualification remain open. The local regional-routing fault gate is not physical WAN evidence. Regional routing requires upgraded endpoints and compatible/strict scoped bookmarks. Readiness profiles do not replace receiver-side causal waiting. Binary downgrade after causal-domain initialization is unsupported; raw writable clones must not be exposed as independent clusters retaining the same causal identity.

Query language

For the clause/function inventory and exact unsupported forms, see the openCypher Compatibility Reference. It also documents accepted-but-partial subqueries and scalar semantic differences; TCK totals are not the current language contract.

In particular, dynamic labels/types (:$(expression)), USING INDEX / USING INDEX SEEK, and scoped CALL (variable) { … } / CALL () { … } are unsupported. Use static labels/types, automatic index selection and CALL { WITH variable … } respectively. Static labels/types are a deliberate design choice: group heterogeneous input client-side into static query templates (see the reference for index-planning rationale and safe parameterization). Backtick-quoted names follow openCypher since #1500: `K` is K and a doubled backtick is one literal backtick. Earlier builds stored a quoted label or relationship type created by CREATE/MERGE/SET with its backticks (CREATE (:`K`) stored `K`) and rejected doubled backticks; data written that way keeps the backtick-wrapped name and must be recreated. A quoted name containing a . still cannot be a property-path segment. CALL db.awaitIndexes(...) is supported since 0.16.0 as a local readiness check, not a replication barrier. Likewise, vector.similarity.cosine(...) is supported as a scalar expression with normalized scores; see the reference for executable examples and argument boundaries.

Derived planner/population metadata is a bounded in-memory cache after the post-0.13.0 audit update (#1270). Cache misses do not commit the live database; reopening starts these derived caches cold. Historical throughput figures are not a new performance qualification of this cache change.

  • The 2026-09-09 openCypher TCK inventory has 3755 / 3897 scenarios passing (#858 inventory, source 6fb6d521): 113 executed failures and 29 scenarios that never ran because of one pinned Gherkin parser limitation. Those 29 count as not passing. The runner is informational and exits successfully despite scenario failures. Failures include procedure validation, graph/path edge cases, grouping/ordering, lists/quantifiers, literals and temporal behavior. They are not all harmless missing error codes. See the compatibility reference for the supported forms rather than the old approximate failure-cluster counts.

    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.

    Outdated grouping warning removed in the #1261 audit: projected aggregate sort keys are rewritten to their output columns before limiting. The existing test_group_by_order_by_raw_aggregate_then_limit_is_deterministic regression passes in the 2026-09-06 core suite. This verifies that specific former bug, not every aggregate-ordering edge case or a new overall TCK pass rate.

  • 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; procedure argument/validation gaps remain in the current TCK run.

  • 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}} — used to be unsupported for the same reason plain arithmetic/string-concatenation in a pattern property was (GH #863, fixed): CREATE (n {m: {inner: i}}) now resolves i correctly, same as a top-level {v: i}.) MERGE, DELETE/DETACH DELETE, and label mutations inside a FOREACH body now execute (GH #1229, fixed): the grammar originally accepted them, but the executor silently dropped them (the original GH #579 bug — a FOREACH (i IN [1] | MERGE (:X)) returned success and wrote nothing; #579 made them a loud parse error as a stop-gap). A FOREACH body is now a source-ordered pipeline of the same updating clauses a top-level body accepts — CREATE, MERGE (match-or- create per loop item, ON CREATE/ON MATCH branches included), SET (including SET n:Label), REMOVE (including REMOVE n:Label; primary-label removal stays the documented no-op), DELETE and DETACH DELETE — each routed through the same constraint-enforcing core as its top-level counterpart, so no accepted body form is ever silently skipped. Each body clause sees the bindings the clauses before it produced in the same iteration (FOREACH (i IN [1] | MERGE (n:X {v: i}) SET n.seen = i)), and later loop items see earlier items’ writes. The loop variable is visible to every body clause, shadows a same-named outer binding inside the body, and neither it nor body-introduced bindings escape the clause (a trailing RETURN i is UndefinedVariable). Entity-valued list items — collect(n)/collect(r), [r]/nodes(p)/relationships(p) entity elements — are rehydrated to real node/relationship bindings, so label and property writes and DELETE work over collected entities. UUID scalar values (including id(n) and collect(id(n))) remain scalar data even when they name an existing node; they are not mutation targets (#1346). collect(n) now preserves the same entity-valued elements as [n], rather than exposing scalar UUID elements. Entity-valued CASE and coalesce expressions retain that identity too. Nodes, relationships and paths cannot be stored as properties, including inside lists/maps; use id(n) explicitly to store a scalar identifier. Entity values in lists/maps use native Bolt Node/Relationship structures (including labels and properties); genuine paths retain their Path type even when they contain only one node. UUID scalar values never resolve graph properties implicitly. JSON entity elements now render as objects with type, id, labels (nodes) or endpoints/type (relationships), and properties, instead of the former <path:1 elements> string. Actual paths retain their existing JSON display. Row-table scalar containers use the same explicit entity type; WASM Node/Relationship wire tags are unchanged. A body DELETE target is validated per item: an entity deletes, a null is a no-op (top-level semantics), any other scalar raises InvalidDelete (never a silent no-op). A nested FOREACH is still unsupported — the grammar has no nested body clause. 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) — whether that stays a hard “not supported” or becomes a follow-on is the open product question in GH #1232 — 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.)

  • No integer element id, and a relationship’s id is its endpoint triple. id(n) / elementId(n) return the node’s UUID (as a string handle); there is no Neo4j-style integer id, so WHERE id(n) = <number> raises an error rather than silently matching nothing (compare against a string element id). A relationship’s id(r)/elementId(r) is its canonical <start>:<end>:<type> handle — unique and stable, but there is no relationship-id index, so a WHERE elementId(r) = … lookup is a scan. The model is id-less by design and cannot store two parallel edges of the same type between the same endpoints. A second CREATE of such an edge is rejected with Neo.ClientError.Schema.ConstraintValidationFailed, before anything is written — the existing edge keeps its properties. Bulk import paths are not covered by that rejection and still overwrite. See Data Model → Parallel edges: what actually happens for the exact behaviour and how to model repeated relationships, and Element identity.

  • No triggers, no stored procedures, no server-side scripting.

  • shortestPath and allShortestPaths are implemented for unweighted traversal. This does not imply support for every Neo4j path-selector shape, weighted shortest-path syntax or the full APOC-style procedure library. Named MATCH supports a single relationship segment with minimum zero or one. Attached MATCH predicates, relationship bindings/properties and additional relationship patterns in that clause are rejected. A post-WITH filter does not search for longer admissible paths. Common endpoints with minimum zero return an empty path, unlike Neo4j with its default common-node prohibition. See Named shortest paths.

  • List predicates (ALL/ANY/NONE/SINGLE) have native planned expressions for their source and condition, with short-circuit three-valued evaluation. Their conditions support XOR with precedence NOT > AND > XOR > OR. XOR evaluates both operands once; a NULL operand yields NULL. Native Boolean-value wrappers preserve strict type errors and keep nested subqueries in the planned path. Further index/hash-join rewrites and general decorrelation across outer rows are not yet implemented. Quantifiers still run per outer row; lists whose elements are nodes/relationships retain the compatibility-reference gaps.

  • Pattern comprehensions compile the pattern, filter and projection together. Matches are consumed one at a time into the result list; match and path-hydration failures abort the query rather than returning partial results. The list is bounded by PATINADB_MAX_AGG_ROWS; exceeding the bound raises ResourceExhausted. Execution remains correlated per outer row.

  • Repeatable EXISTS expressions can reuse Boolean results for repeated typed dependencies within an operator, sharing COUNT’s bounded auxiliary budget. Distinct keys may cost more; complex tails still materialize on cache misses. Unknown dependencies use full-row keys. Writes and unproven effects do not gain cross-invocation reuse; this is not general decorrelation.

  • EXISTS and COUNT retain accepted trailing WITH/RETURN clauses in their subplans: filters, DISTINCT, WITH-SKIP/LIMIT and aggregation affect the final rows. Outer variables remain available after WITH, and assigning a different value to an outer name is rejected. Missing parameters are checked even when the outer query has no rows. Full pipelines can materialize intermediate rows and are subject to the existing memory bounds; this does not add support for every Cypher subquery syntax or decorrelate repeated seeds.

  • COUNT { pattern [WHERE ...] } uses a seeded subplan in projections and comparisons, including correlated WHERE filters (#1230). Its scalar evaluation counts matching rows as they arrive rather than collecting them. This does not perform general decorrelation. Eligible deterministic projections may reuse a complete projected row when every proven input dependency has identical typed values within the same operator and snapshot. Unused outer columns can be omitted from the cache key; dependencies include all projected expressions, pattern seeds and grouped-value slots. Unsupported dependency shapes keep the complete input-row key. The memo has an 8 MiB serialized-data budget and the aggregate row cap; oversized/uncached inputs keep normal execution. Volatile expressions, writes and unproven effects disable reuse. Individual COUNT expressions can also reuse their own integer results by proven typed dependencies while unrelated outputs vary; unknown forms retain full-row keys. Graph patterns and trailing subplans qualify under the same effect proof. Different keys still execute separately unless the independent equality-scan path applies. Scalar result entries and equality tables share one auxiliary entry/byte budget; errors are never cached. COUNT expressions in a projection, filter, aggregation, sort or UNWIND source, including nested expression containers, can additionally reuse bounded equality counts from the first independent node scan or fixed-length graph path with equality between proven snapshot-stable inner-only and outer expressions. The inner expression may combine properties from multiple inner bindings; mixed inner/outer dependencies retain ordinary execution. Outer properties, parameters, arithmetic and containers are supported when independent of all inner bindings; direct id()/elementId() comparisons retain their special ordinary path. All such tables share one auxiliary budget per operator and preserve lazy evaluation and lexical bindings. All named nodes and relationships must be independent of outer bindings. A snapshot-stable inner-only leading AND prefix is admitted without reordering; false skips the equality, NULL retains its error evaluation. Trailing or unproved predicates remain ungrouped. Repeated names, variable-length paths, property constraints, indexed/seeded patterns and more complex inner plans are not admitted. Unsupported values or auxiliary-budget exhaustion retain normal execution; the first scan still incurs grouping work. The auxiliary table has a separate 8 MiB accounting budget and the aggregation entry cap. See query planning for exact admission and accounting. Filters include ordinary/seeded WHERE and inner COUNT/EXISTS predicate loops; each inner invocation owns its context. Serial aggregation shares one context for arguments, grouping and group-key output. Parallel aggregation uses separate worker contexts and a later finalization context on the pinned snapshot. Budgets apply per context, not globally across workers. Full sort and bounded top-K retain one context across sorting windows after proving the input and sort expressions repeatable. Distinct keys may incur overhead; unknown callers keep ordinary evaluation. UNWIND expression sources retain per-source contexts within one repeatable APPLY execution; nested invocations do not share them. Existing expansion caps still apply. This is not general decorrelation.

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 (a follow-on — GH #1234).

Storage & scale

See How Storage Works for the layout, caches and memory model these limits refer to.

  • 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, and multi-key sorts over a single labeled node — any per-key direction mix, plain field keys — are served index-side by the covering-compound (same direction) or leading-key partial-prefix scan (subject to cardinality stats, no covering index needed), with or without LIMIT. A covering compound index does not disable the mixed-direction partial-prefix path (#1233). The general executor’s O(N) sort still covers the rest: expression sort keys, DISTINCT sorts, traversals/multi-node patterns, label-less MATCH (n) ORDER BY n.p, mixed/unencodable sort columns, and shapes the stats gate declines — a follow-on — GH #1233 — 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 postcard, 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 implemented — a possible follow-on (GH #1231), deliberately unscheduled until a concrete requirement shows up: the design is mapped and the sensitive-property case (RETURN p.salaryField on a granted label) is otherwise covered only by the whole-entity rejection above or by application-side authorization. Auth is fail-closed: an empty password refuses to start unless you pass --insecure-disable-auth.

  • Security audit persistence is asynchronous and node-local. Authenticated write/admin/DDL operations and authorization denials feed a recent in-memory ring (GET /mgmt/audit), a background durable writer, and the patinadb::audit tracing target. Committed entries survive restart, but request responses do not acknowledge audit fsync. Hard crashes, queue saturation or storage failures can lose events. The log is not Raft-replicated; successful reads are not generally audited. See the audit contract.

  • No encryption-at-rest. The on-disk graph and persisted audit records are not encrypted by patinaDB — use OS-level disk encryption. This is a deliberate product decision, not pending work: OS FDE is a REQUIRED operator control (see Security → Encryption at rest; GH #381, completed).

  • 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 — or a COMMIT that loses leadership mid-request — is rejected with the transient Neo.TransientError.General.TransientError), 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 every official Neo4j driver retries 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. (Earlier patinaDB versions surfaced this as Neo.TransientError.Transaction. LockClientStopped — the official Python driver’s error-rewrite map reclassifies that specific code to a non-retryable ClientError, and the Java/JS 4.4-line drivers exclude it from retry by name too, so a routine conflict reached the application as a hard error under session.execute_write()/executeWrite. Fixed: patinaDB now emits the generic transient code every official driver variant retries.) Server-side, the same conflict-and-retry mechanism ALSO governs pipelined autocommit writes internally (a bounded number of server-side re-resolve-and-propose attempts before it, too, surfaces the transient code) — but a plain autocommit RUN is never automatically retried by the driver itself, in any version, since it isn’t wrapped in a managed-transaction function; a backend issuing autocommit writes under contention must still implement its own retry at the application level. 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. The scanning case is now covered too: an explicit transaction reads its snapshot base through an MVCC read handle pinned at BEGIN, so a large scan inside a transaction no longer copies the touched range (measured ~1.4× faster on a scan-in-transaction workload — bench_mvcc_snapshot_vs_reconstruct_scan; see Caching for how to size memory around it).

    Keep explicit transactions short. An open transaction pins a storage read version for its whole lifetime, which holds back reclamation of pages superseded by concurrent writes. This is the ordinary MVCC trade-off, but a transaction left open for minutes under write load will grow the on-disk file. Commit or roll back promptly; the idle-transaction reaper (--idle-tx-timeout-secs, default 300 s) is the backstop, not the plan.

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).

History epochs (RFC-0038)

See History Epochs.

  • The archive is a node-local filesystem directory that every member must see under the same path. There is no object-store (S3) backend.
  • A hot release needs a fresh receipt from every member, learners included. One offline member holds every release up.
  • A tag or pin inside a range blocks its release. Tags cannot be created in released history, and a fork into released history is refused.
  • Collection below retention requires a replicated metadata proof verified on the collecting node. New members use that proof to complete their history metadata after full archive deletion; expired states remain unavailable. If an older installation already collected the archive, a member with complete retained metadata must publish the proof. Without either source, backfill remains incomplete and retries (#1428). Proof publication requires protocol 20.
  • After a release, CALL patinadb.squash and prepared squash jobs are refused for that database; only a retention squash whose cutoff lies above the released range runs.
  • Cold reads hydrate the whole epoch segment up to the target into a temporary store, at most two at a time per process. Their latency, the export cost, archive size and the disk space a release returns are unmeasured.
  • A references_only backup is JSON-only and needs the archive at restore time. Its archive hold stays until an operator removes it. An offline patinadb-admin archive backup cannot read the archive and fails for a database with released history.
  • History restore, including a reference-only release index, requires all members on peer protocol 21 and replicates bounded encoded blocks (#1419). The decoded history still consumes proportional memory. Graph restore remains chunked; its rollback does not undo schema changes or an already published timeline.

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.

Ontology view (RFC 0039)

CALL patinadb.ontology() and the console’s Ontology screen describe only the current committed state of one database, exactly and synchronously. Historical (AS OF) views and comparisons, sampled discovery, discovery jobs or paged results, SHACL export, MCP access and scoped (filtered) ontologies for restricted principals are unavailable in this release. Discovery is off by default (PATINADB_ONTOLOGY_DISCOVERY). Default limits refuse very wide schemas (more than 50 000 records or 32 MiB encoded) instead of truncating them. A principal with any label-, relationship-type- or relationship-property -scoped grant on the database is refused rather than filtered. Unnamed single-property CREATE INDEX ON :L(p) markers are not reported as declarations (they are not label-scoped), uniqueness constraints are not validated against the data, and nested list/map element shapes are not described. A console instance serves one database; there is no database selector on the screen. Collection reads every node and relationship; performance under concurrent load and with a cold page cache is unqualified. See Database Ontology View.

Tracked work

The limitations above that are called out as “not yet” or “a follow-on” are on the roadmap. Each is listed here with the wording it carries above, so you can find it:

  • general decorrelation — eliminate repeated subquery work across outer rows, preserving correlation, multiplicity, NULLs and volatile expressions (GH #1230).
  • deliberately unscheduled — per-property RBAC grants on nodes (relationship properties already have theirs): designed and parked, scoped when actual demand arrives. (GH #1231)
  • element-type refinement (bare LIST only — the LIST<T> form). (GH #1232 — note: the body calls this “Not supported” while this section lists it as roadmap; which of the two is true is an open product decision, named in the issue.)
  • Some sort paths are O(N). — the O(N) sort fast-path integration. (GH #1233)
  • Postings are updated read-modify-write with no segment merging. — the deferred full-text items above. (GH #1234)

If a limitation here blocks you, reach out to your vendor or account representative to check on its status.

IAM qualification boundary (RFC0037)

IAM implements local principals and profiles, provider-backed OIDC/OAuth and SCIM paths, a replicated authorization write fence, and administrative-restore quarantine. Its bounded local test matrix has passed, but this is not release qualification. Only Keycloak 26.8.1 with the documented recipe (including Console sign-in) and the official Python neo4j driver 6.2.0/5.28.2 have been checked against a real IdP; Microsoft Entra ID, Okta, other drivers and real SCIM provisioning services remain unqualified. SCIM was checked with the generic scim2-tester client and has documented deviations: no POST /.search, eq filters only (case-sensitive), a single primary email, active cannot be removed, unknown group members are a retryable 409, and externalId is immutable once provisioned. SCIM over plain HTTP is not refused on a node without TLS. No authentication, authorization or revocation timing has been measured. Console sessions are node-local in-memory and do not provide shared browser-session continuity across nodes. Strict freshness is opt-in for external principals, not a default cluster-wide instant-revocation guarantee. The current candidate adds per-delivery authorization revalidation to Console /changes SSE, including replay, live events and resynchronization, with terminal errors and buffer clearing on expiry, revocation or unavailable checks. Targeted expiry and revocation regressions passed in the source-bound local validation. This does not promise instantaneous cluster-wide revocation before a change is committed; PR CI verification remains. The current API has no REST explicit transaction-handle interface; /mgmt/transactions manages the node-local Bolt transaction registry only. See IAM and RFC0037.

PR #1426 review corrections passed fresh local regression, crash and acceptance checks; PR CI remains required before merge. Admin Console queries require recent authentication even for reads; pending OIDC login cookies are invalidated on Console restart. Portable identity restore requires protocol 19 and a single identity bundle within the entry-size limit. A portable restore merges backup IAM records over the target, so an identity deleted after the backup was taken comes back (providers stay quarantined until released) and target suspensions/disables take the backup’s values; the intended merge policy is an open RFC0037 question. The Identity & Access screens edit records as structured JSON forms; the RFC’s selector/review workflow, user search and “affected users” preview are not implemented yet. See IAM.

The LSQB SF0.1 follow-up on PR #1410 still has original-query timeouts and Q1/Q2/Q5 regressions (see the repository coverage ledger). UUID entry and typed bound-target corrections alone do not qualify the query engine’s performance. Direct label-registry read reuse is under validation; it adds no setting or changes to stored label IDs, query results, memory caps or durability.

On PR #1410 candidate9c8929aa, original SF0.1 Q3 now completes with the exact published count in the native comparison. The full suite still has timeouts and Q1/Q2/Q5 regressions; no general performance qualification follows. See the repository’s LSQB coverage ledger for the first calls, repeats and conditions.

Epoch datetime map boundaries

datetime({epochMillis: ...}) accepts integer Unix milliseconds and an optional timezone. Calendar storage uses signed32-bit epoch days; unsupported extreme instants and override years are rejected, with a tighter bound for named zones. Calendar overrides into nonexistent local daylight-saving times raise an error instead of Neo4j’s forward adjustment. See Cypher.

Schema 8 upgrade (RFC 0040)

Schema-7 stores migrate in place to schema 8; older incompatible schemas retain existing export/re-import requirements. Inline secondary labels are enabled by default. The first open of an existing store without READY rebuilds adjacency values once using sorted staging and atomic table swaps. On the private SF1 copy this took about 95 s, with +0.68% allocated space after offline compaction (RFC 0040 “Increment 1 measurements”, #1442). Temporary space is needed during the rebuild. PATINADB_INLINE_LABELS=0 skips automatic ensure; it does not undo an existing READY marker or stop writer maintenance. See Day-2 operations. Schema-8 physical snapshots require protocol 22 (snapshot format 8) on every member. Mixed-version clusters fall back to logical transfer. Clusters with a causal domain need every member at protocol 21 or later for the logical causal format (7); below that they cannot create new snapshots. Plan that upgrade before relying on snapshot-based catch-up. Downgrading a schema-8 database is not supported.

Supply-chain gate rollout

The full-repository license check covers demo and fuzz lockfiles. The demo uses pinned Vite7.3.7 without the MPL lightningcss dependency; the reviewed libfuzzer-sys0.4.13 NCSA conjunction has an exact package/version exception. Other versions and unapproved licenses still fail. Runtime application dependencies still reject unknown or unapproved licenses. The approved Docker OS profile admits only exact reviewed base/package/source/ notice identities; it never substitutes for notices and exact source delivery. The aggregate branch-protection check is activated only after the updated workflow passes on main. Full multi-platform validation remains required; synthetic tests alone do not qualify release images.

Planner entry-cache accounting (#1467)

The optional A2 region planner.entries uses estimated ownership bytes, not an allocator/RSS ceiling. Keys are reserved before copying; unusually deep values and function/expression literals without a sizing model skip reuse and use the normal planner. Per-database statistics omit construction and evicted entries still pinned by readers; the global level byte count includes both. This change has correctness controls, but no new throughput or memory-pressure soak claim.

The HTTP pre-proposal outcome receipt is deliberately narrow: only non-chunked upserts and REST autocommit data writes refused during private snapshot admission carry write_outcome: "not_submitted". Reads, chunked writes, schema commands and post-proposal failures do not gain this receipt. It is not a general rollback certificate for arbitrary 503 responses or chunked loads. The new five-hour/15–20GiB-per-node qualification under 5 GiB RAM is still open. The source-bound 2e9b7d67 small smoke passed, but the subsequent full attempt stopped during chunked bootstrap before mixed load (#1483).

The chunked graph validation wait covers only read-only snapshot admission before the next proposal, within the existing query deadline. It is not automatic recovery or rollback of a partially committed import. The latest full attempt stopped during bootstrap with such a partial load; no five-hour qualification is claimed (#1483).

The next d98f7c0e smoke stopped earlier in bootstrap on an uncertified Cypher snapshot refusal. Retained graph-copy readback matched the acknowledged state on all five nodes, but does not retrospectively certify the unknown write’s outcome. The REST Resolve-only receipt correction still requires fresh runtime qualification (#1483).

The standalone soak backend has a pending pre-RUN timeout correction: only atomic prevention of the first write statement certifies non-submission. Timeouts after submission remain ambiguous and require readback. The latest 4-GiB-per-node run failed before completing three hours; this correction has not yet passed a new full pressure qualification.

Pipelined write snapshot admission

Before executing a pipelined Cypher write, Bolt and REST may wait for a complete native read snapshot within the existing query deadline. Only snapshot acquisition is repeated: this does not replay the query, a WASM function, or a submitted mutation. Other storage errors propagate. If the query timeout is disabled, or the calling thread already owns a deferred native write, an unavailable publication is refused rather than waited on. The network server’s existing default query timeout remains 300 seconds; no new setting is added. The focused publication-race regression is verified; renewed full pressure qualification of this change is still pending.

Fork publication under concurrent access

A fork becomes visible in its registry only after its copied graph and genesis initialization succeed. Registry lookups can wait while that initialization holds the registry publication lock; source reconstruction precedes the lock. A failed initialization removes only the new target directory owned by that attempt. This live-publication guarantee is not crash-atomic fork creation.

Seeding the target writes its graph, fulltext and vector indexes, genesis history and HEAD under one durability barrier (#1511). A crash during seeding therefore leaves an empty target with no HEAD, never a partly seeded one. The empty target directory itself can remain after such a crash. A local fork of the soak’s history_probe copy went from 94 to 16 syncs; opening the new database store accounts for most of the remaining 14 (#1511).

Fork preparation keeps its target unpublished until graph and history are complete. Namespace changes are serialized; reads of existing databases do not acquire the fork lifecycle lock. The fork correction is covered by a gated lookup regression and namespace-race tests. CREATE and physical install retain their existing map-lock duration; concurrent namespace mutations can still wait for fork completion. The registry-lock change passed the full pressure soak 0185c2321912 (#1495); the single seed barrier is newer and not yet soaked.

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.