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

Limitations

This chapter is the honest inventory of what patinaDB does not do, or does differently from Neo4j. Read it before committing to a production workload.

Query language

  • ~3% of the openCypher TCK does not pass (~3755 / 3868 scenarios pass, ~97.1%; ~113 failures — this number moves by a handful run to run because of a known, harmless grouping non-determinism, see below). The TCK is a prioritized gap report, not a guarantee. The failures are not separate bugs — they cluster as follows:

    Cluster~ failsWhat it is
    Missing typed errors34Negative tests expecting a specific error (InvalidArgumentType, DeletedEntityAccess, MergeReadOwnWrites, …). patinaDB is more permissive and doesn’t raise them — a validation gap, not wrong results on valid queries.
    User-defined-procedure DDL/YIELD gaps~16Standalone CALL proc with no parentheses, standalone CALL proc(...) YIELD *, a backtick-quoted RETURN … AS \ident`alias, and missing compile-time validation (arg-count/type mismatch, YIELD-alias shadowing) for aCALL`. The 36 other procedure-fixture scenarios (registering + invoking a test procedure) now pass.
    Quantifiers / comprehensions over entity lists~14any/all/none/single and list comprehensions whose list holds nodes/relationships (not scalars), plus statically-true-predicate edge cases.
    ORDER BY edge cases~10Ordering by expression / aggregate / cross-type value ordering.
    Temporal edge cases~89-digit extended-year date parsing, datetime timezone serialization, duration.between over huge spans.
    Aggregation grouping~3Aggregates inside non-aggregate expressions; multiple aggregates on one variable.
    Long tail (lists, literals, precedence, …)~28Many features with 1–3 scenarios each; no further large cluster.

    OPTIONAL MATCH … WHERE was closed (2026-07-08): a per-clause WHERE on an OPTIONAL MATCH now filters the optional side before the left-join null-extension (a non-matching predicate yields NULLs, not a dropped row), and a chained OPTIONAL MATCH binds variables introduced by an earlier optional. A negative sub-second-only ISO duration (P1DT-0.001S) also round-trips correctly now.

    MERGE ON CREATE / ON MATCH is supported: SET x = <entity>, SET x += <map/entity> (nodes + relationships), SET x:Label, null-in-map removal, and combined ON CREATE+ON MATCH all work, plus MERGE matches-or- creates per row and undirected MERGE matches either direction.

    Known non-determinism (to fix): GROUP BY … ORDER BY <aggregate> LIMIT n applies the limit in group-insertion order rather than after sorting by the aggregate, so the surviving rows can vary run to run. Read-only today (it does not diverge Raft followers, which apply leader-resolved ops), but a real correctness bug.

  • User-defined procedures exist, but only as read-only, sandboxed WASM modules. CREATE FUNCTION <name> LANGUAGE wasm FROM 'file://…' EXPORT '…' registers a compiled WebAssembly module, callable as CALL wasm.<name>(...); it cannot write to the graph, and has no filesystem/network/clock/RNG access (deterministic-by-construction, so it’s safe to replicate). It is not a scalar UDF — it can’t be used inline in an expression like RETURN f(n.x). See WASM Procedures. This is a different mechanism from the TCK’s own ad-hoc test-fixture procedures (there exists a procedure …), which the TCK harness now implements directly against the same CALL dispatch — see the DDL/YIELD gap row above for what still fails there.

  • Constraints: all four Neo4j kinds — uniqueness, existence (NOT NULL), node-key/relationship-key, and property-type — are supported on both nodes and relationships. CREATE CONSTRAINT [name] [IF NOT EXISTS] FOR (n:Label) REQUIRE n.prop IS UNIQUE (plus the Neo4j-4 ON (n:Label) ASSERT … form and the shorthand ON :Label(prop)), REQUIRE n.prop IS NOT NULL (existence), REQUIRE (n.p1, n.p2) IS NODE KEY (composite unique + existence over the key), REQUIRE n.prop IS :: <TYPE> / IS TYPED <TYPE> (property-type), and the relationship forms FOR ()-[r:TYPE]-() REQUIRE r.prop IS NOT NULL | IS :: <TYPE> | IS UNIQUE and REQUIRE (r.p1, r.p2) IS [REL|RELATIONSHIP] KEY are all supported, along with DROP CONSTRAINT name [IF EXISTS] and SHOW CONSTRAINTS; all replicate across a cluster and are carried in Raft snapshots + portable backups. Enforcement is at write time on CREATE, MERGE, and SET/REMOVE for nodes and on edge create/SET/REMOVE for relationships (a UNIQUE NULL is exempt; existence/key rejects a missing/null required property, including a SET n.p = null or REMOVE n.p; property-type rejects a present wrong-typed value but allows a missing/null one); CREATE CONSTRAINT refuses to run if the existing data already violates the constraint. FOREACH-body mutations are enforced too (GH #575, fixed): SET (per-property and map-form +=/=) and REMOVE inside a FOREACH (… | …) block route through the same enforcing core as a top-level SET/REMOVE, so a FOREACH write can no longer bypass a declared UNIQUE / existence / node-key / property-type constraint. A FOREACH-body CREATE pattern property that references the loop variable now resolves correctly (GH #578, fixed): FOREACH (i IN [1,2,3] | CREATE (:Tag {v: i})) creates nodes with v:1/v:2/v:3, not v: null — this was a separate, pre-existing data-correctness bug found while testing #575 (apply_foreach’s CREATE-path resolver only threaded vertex bindings, never the scalar loop variable, into the pattern-property resolver); toInteger(i) and other function-arg positions resolve it too. (A loop variable nested inside another map literal as a pattern property — {m: {inner: i}} — is still unsupported, but that is a separate, pre-existing parser limitation orthogonal to FOREACH — it reproduces identically with a plain UNWIND … AS i CREATE (n {m: {inner: i}}), no FOREACH involved.) MERGE/DELETE/SET n:Label inside a FOREACH body are rejected with a clear parse error (GH #579, fixed): the grammar used to accept them but the executor silently dropped them — a FOREACH (i IN [1] | MERGE (:X))/| DELETE m)/| SET m:VIP) returned success and wrote nothing. They now fail loud at parse time instead (SET/REMOVE/ CREATE inside a FOREACH body are unaffected); wiring MERGE/DELETE/label- add up to actually execute is a documented follow-on. Node-key and relationship-key uniqueness are each served by an index (an automatically registered backing compound index for node-key; the existing edge-property value index for relationship-key/uniqueness) — no separate index to create. Not supported: LIST<T> element-type refinement (bare LIST only), and bare composite-UNIQUE without existence. See Constraints. (Index DDL — CREATE INDEX ON :Label(prop) and compound :Label(p1, p2)is supported and replicates across a cluster; see the Data Model.)

  • CREATE EDGE SORTED INDEX is not carried in Raft snapshots. The DDL replicates via the Raft log (re-run per node), so all live nodes serve it, but a node that bootstraps purely from a streamed snapshot after the log is purged will lack the def and fall back to traverse + sort until the DDL is re-issued. (Correctness is unaffected — the fallback returns identical rows.)

  • No triggers, no stored procedures, no server-side scripting.

  • Side-effect counters are not tracked — query summaries don’t report nodes created, relationships deleted, etc. TCK side-effect assertions are accepted as no-ops.

  • shortestPath is supported, but the broader weighted/all-shortest-paths and full APOC-style procedure library are not.

  • Some advanced predicate forms (ALL/ANY/NONE/SINGLE list predicates, COUNT { … } subqueries, pattern comprehensions) are evaluated per-row (O(N)) — not yet lowered into the streaming operator tree for index/hash-join shortcuts — and the quantifiers do not yet fully handle lists whose elements are nodes/relationships (see the TCK table above).

  • String properties only (matching Neo4j). No numeric/range terms inside the full-text query string.
  • No phrase slop / proximity"a b" matches only exactly-consecutive terms; "a b"~3 is not supported.
  • No highlighting or snippet extraction.
  • Prefix/fuzzy expansion is capped at 256 terms per term to bound cost; very broad prefixes silently match only the first 256 dictionary expansions.
  • Postings are updated read-modify-write with no segment merging. This is fine for typical write rates but is not engineered for very high write throughput over a large indexed corpus.

Storage & scale

  • Single-machine storage. Each node holds the full graph; there is no horizontal sharding of one graph across machines. The cluster replicates, it does not partition.
  • The whole graph is on one B-tree file per database (patinadb.redb). patinaDB targets small-to-medium graphs, not multi-terabyte datasets.
  • Time-travel speed (not memory) grows with delta distance. Snapshots are captured and reconstructed as a streamed O(chunk) record run — building a periodic snapshot and rebuilding a past state both use bounded memory (the reconstruction runs into an on-disk temp store, not RAM), so the graph is not capped by memory. What still grows is time: reconstructing a point far from the nearest snapshot replays a longer delta chain, so frequent snapshots keep far-back queries fast. A narrow AS OF read now scales with what the query touches, not the whole graph, once its snapshot window is warm (RFC-0011 increment 1): the nearest snapshot is materialised once and shared across every time-travel read in its window, and a query reads through it lazily with only the in-window deltas overlaid — so history browsing / AS OF dashboards / diff exploration over a large graph are fast. The first read into a cold snapshot window still pays the O(graph) snapshot materialisation (cold O(query) for point reads is RFC-0011 increment 2).
  • A plain LOAD CSV … CREATE buffers into one transaction. The CSV source streams row by row, but a bare (un-chunked) write buffers all resolved ops into a single transaction (on the server, one Raft entry) before committing — so a very large LOAD CSV … CREATE has an O(rows) memory cliff. Wrap it in CALL { … } IN TRANSACTIONS [OF n ROWS] to chunk it into many small commits with O(n) memory — one engram per chunk, and on a cluster one client_write per chunk. For a large columnar file already on the server’s disk, POST /mgmt/upsert (index-accelerated match-or-create, chunkable) is faster still. LOAD CSV … RETURN row (read) streams end-to-end. See Bulk Loading & Import.
  • Some sort paths are O(N). Single-key ORDER BY uses an index fast-path, but the general executor still has O(N) sort paths for cases not covered by the fast-path (e.g. an un-limited multi-key sort with no covering index) — see Query Planning.
  • On-disk format evolution needs a migration or a dump/reload. Each database records an on-disk schema version. Because the storage encoding is non-self-describing bincode, appending a variant to the end of a persisted enum is compatible and needs nothing, but a struct field add/remove/reorder, an enum variant insert/reorder, or a key/value byte-encoding change is breaking and bumps the version. An older database is not unconditionally bricked on open: if a migration step is registered for the version boundary it is upgraded in place; if none is registered the open fails loud and points you at the dump/reload path — export with the old build via the portable backup (GET /mgmt/snapshot), upgrade, then import (that backup is JSON and cross-version by construction). A database written by a newer build than the running binary is always rejected (there is no downgrade).
  • Cross-tree atomicity is guaranteed by the embedded B-tree storage engine, whose write transactions are natively cross-tree atomic. A commit, a Raft-applied entry, and a snapshot install each land as an all-or-nothing transaction of any size: a crash before the terminal durability barrier rolls the store back to the prior root. This is proven non-vacuously by the crash-recovery harness (a torn cross-tree flush at the property↔value-index seam recovers atomically). The Raft leader resolves a data-mutating query against a throwaway mirror and applies the resolved batch as one crash-atomic engram, so a very large single-query write never partially mutates the live graph.

The server

  • Native TLS (--tls-cert/--tls-key) covers REST + management + peer RPCs and the Bolt endpoint (bolt+s:// / wss://) (Authentication & TLS). It’s opt-in; without it everything is plaintext (terminate at a reverse proxy instead).
  • Firewall the Bolt port regardless of TLS. Issue #440 closed the unauthenticated “slowloris” connection-exhaustion vector: a silent, a stalled-mid-handshake, or an idled-after-HELLO socket is now bounded in time by --bolt-handshake-timeout-secs (default 10) / --bolt-idle-timeout-secs (default 300) and can never spend the --max-bolt-connections permit budget (default 1024, acquired only after a successful LOGON — see Connection resource limits). The Bolt endpoint is nonetheless still best run reachable only from your trusted backend network, not the open internet — every other resource (accepted file descriptors, per-message CPU) is still finite.
  • RBAC: global + per-database roles + per-label grants (row-filtered where provably safe) + optional strict relationship-type/property modes + optional closed-mode tenant isolation. Per-user accounts with admin/writer/reader roles, per-database overrides (GRANT … ON DATABASE …), and per-label READ/WRITE grants (GRANT READ|WRITE ON <db>:<Label> …) exist (Authentication & TLS). By default (open mode) a user’s global role reaches every database, including one it holds no grant on; --rbac-closed opts into real database-level deny for non-admins (see Authentication & TLS). Per-label enforcement for a read query is row-filtering, not a blanket reject, wherever it can be proven safe: a read that touches only granted labels returns full rows; a read that would observe any ungranted label (e.g. a traversal into it) returns an empty result — as if the ungranted nodes didn’t exist — rather than every property/value on the granted side being withheld one by one; only a read the analyzer cannot statically classify (an unlabeled MATCH (n), a procedure CALL, or a query that projects a whole matched entity that might carry an ungranted secondary label) is rejected outright, since row-filtering it could otherwise leak data through a side channel. A write touching an ungranted label is always rejected (a write can’t be partially applied). Two more layers are opt-in and reject-based (not row-filtered): --rbac-rel-grants (relationship-type grants — without it, a label-scoped user still reads all edge data) and --rbac-rel-property-grants (per relationship property). Per-property privileges on nodes are not yet implemented. Auth is fail-closed: an empty password refuses to start unless you pass --insecure-disable-auth.
  • Security audit log is node-local + in-memory. Authenticated write/admin/DDL operations and authorization denials are recorded (GET /mgmt/audit, plus tracing target patinadb::audit), but the ring is bounded, not Raft-replicated, and not persisted across a restart; reads are not audited.
  • No encryption-at-rest. The on-disk graph (and the audit ring) are not encrypted by patinaDB — use OS-level disk encryption. This is a storage-backend change tracked as a follow-on.
  • No online membership reconfiguration UI — cluster changes go through the /mgmt/* HTTP endpoints by hand.
  • Writes are linearizable through Raft, so write latency includes a consensus round trip on a multi-node cluster. (A single --bootstrap node has no such cost.)
  • Reads are served from a node’s local applied state by default. On a follower/learner this can momentarily lag the leader. For a read that reflects all committed writes, request "consistency": "linearizable" on /cypher (leader-only, one round-trip — see High Availability). This knob is REST-only; the Bolt path always reads locally.
  • Explicit Bolt transactions are snapshot-isolated, not serializable — and your client MUST retry the conflict error. BEGIN pins a consistent snapshot (repeatable reads); COMMIT is first-committer-wins (a write-write conflict against a commit made since the snapshot is rejected with a transient Neo.TransientError — surfaced to a Neo4j driver as the same LockClientStopped-shaped transient conflict a real Neo4j cluster can also raise), which prevents lost updates. This is a hard requirement for concurrent multi-writer correctness, not an edge case to shrug off: use your driver’s managed-transaction API (execute_write/executeWrite, or equivalent), which retries a transient error automatically — a client that treats it as a hard failure will see avoidable errors under write contention even though the database itself never lost or corrupted any data. The same pipelined-write conflict-and-retry applies to autocommit writes on the server too. Snapshot isolation does not prevent write skew (a read-write conflict with disjoint write-sets), and it is not serializable and not linearizable. For an invariant spanning rows one transaction reads and another writes, use a single autocommit statement, a sentinel node your transactions all touch (forcing the conflict to be detected), or an application-level guard. See Bolt & Neo4j Browser.
  • An explicit (BEGIN…COMMIT) transaction’s snapshot base is fast for a selective read, and still has a residual cost for a genuinely-scanning one at very large scale. Earlier, a managed transaction rebuilt its whole snapshot-isolation base from scratch on every statement, which under concurrent write load could mean nearly every transaction re-materialized the entire graph. That base is now shared and incrementally maintained, and a further fix removed a redundant planning-time cost for the common case — so a selective managed read (a point lookup, an indexed match) stays fast and roughly flat as the graph grows, even under heavy concurrent writes. What’s not yet fixed: a managed transaction that genuinely scans a large, uncovered portion of the graph still pays a cost that grows with graph size under write churn; closing that fully needs an MVCC-style read handle that has been built and reviewed but is not yet merged to main. If your workload runs frequent large scans inside explicit transactions at very large scale, measure before committing to it.

Time travel

  • Reads only. Time travel never writes. To bring a past state back to the live graph, use CALL patinadb.restore('<id>') — it promotes that state to HEAD as a new engram (append-only; see Engrams).

Compatibility

  • patinaDB implements a subset of Neo4j. The Bolt endpoint and the system shim are sufficient for the official drivers and the Neo4j Browser, but Neo4j-specific admin surfaces, APOC, GDS, multi-database access control, and enterprise features are not present.

Tracked work

The limitations above that are called out as “not yet” or “a follow-on” are on the roadmap (LIST<T> element-type refinement, the O(N) sort fast-path integration, lowering list-predicate/subquery forms into the operator tree, edge-sorted-index defs in Raft snapshots, and the deferred full-text items above). If a limitation here blocks you, reach out to your vendor or account representative to check on its status.