Bolt & Neo4j Browser
The server speaks the native Bolt protocol, so the official Neo4j drivers and the Neo4j Browser connect to patinaDB directly. This has been validated end-to-end against Neo4j driver 6.2.0 (over both raw TCP and WebSocket) and the Neo4j Browser (node creation plus path/relationship graph visualisation).
Connecting
The Bolt listener (default 127.0.0.1:7687) auto-detects the transport: a raw
Bolt TCP handshake or a WebSocket upgrade (the Browser’s JavaScript driver speaks
Bolt inside WebSocket binary frames). Both work on the same port.
Neo4j Browser: open browser.neo4j.io, connect to bolt://localhost:7687,
authenticate with --auth-user / --auth-password.
Official driver (Python):
from neo4j import GraphDatabase
drv = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "secret"))
with drv.session() as s:
s.run("CREATE (a:Person {name:$n})", n="Ada")
for rec in s.run("MATCH (n:Person) RETURN n"):
print(rec["n"])
drv.close()
Nodes, relationships, and paths come back as proper Bolt graph types
(Node / Relationship / Path), so drivers hydrate them as graph objects and
the Browser visualises them.
Protocol version negotiation: the server negotiates whatever Bolt version a
client offers during the handshake, including modern 5.x. A client that only
speaks an older version still works, with reduced fidelity where that version’s
wire format is narrower — for example, a client negotiating Bolt 4.4 (some
non-official/community drivers, e.g. Rust’s neo4rs 0.9, only offer 4.4) does
not receive the newer element_id field on nodes/relationships; use id(n)
in Cypher instead if you need a stable per-node identifier over such a client.
Spatial values over Bolt
A patinaDB Point is sent as a native Neo4j Point struct, so official
drivers decode it as a first-class point (neo4j.spatial.Point / a driver’s
Point type):
- a 2-D point → PackStream struct tag
0x58with fields{srid: Integer, x: Float, y: Float}; - a 3-D point → tag
0x59with an addedz: Float.
The srid is the CRS discriminant patinaDB already stores — cartesian 7203,
cartesian-3d 9157, wgs-84 4326, wgs-84-3d 4979 (the same SRIDs Neo4j
uses). For wgs-84 the point’s x is the longitude and y the latitude.
A point can also be passed as a parameter: send $center as a 0x58/0x59
struct and it decodes back into a point you can use in Cypher, e.g.
RETURN distance($center, n.loc).
Polygons have no native Bolt type. Bolt only defines Point2D/Point3D, so a
patinaDB Polygon is returned as a Map —
{type: "Polygon", srid: Integer, rings: [[[x, y], …], …]} (rings[0] is the
exterior ring, rings[1..] the holes). A driver therefore sees a plain map, not
a spatial object; decode it yourself if you need the geometry.
Authentication
Auth is checked at LOGON (scheme: "basic"). An unauthenticated connection
cannot run queries. See Authentication & TLS.
Streaming reads
Read results stream lazily: the query runs on a worker thread that pushes
hydrated records over a bounded channel, with PULL n batching and backpressure.
Memory stays bounded regardless of result size, so you can stream large result
sets without materialising them all server-side.
Transactions
Both autocommit and explicit transactions are real.
- Autocommit (a bare
RUN): each statement commits on its own as one engram — atomic, even for a multi-clause or bulk statement. - Explicit (
BEGIN … COMMIT/ROLLBACK): statements betweenBEGINandCOMMITare buffered, not committed one by one.BEGINpins a consistent snapshot of the database; everyRUNsees that snapshot plus the transaction’s own uncommitted writes (repeatable reads + read-your-own-writes);COMMITapplies the whole buffer as one atomic engram (a single Raft entry on a cluster) after a conflict check;ROLLBACKdiscards it — nothing is written.
Explicit transactions run at snapshot isolation (SI). A transaction is bound
to one database — a mid-transaction USE <other> is rejected — and schema/admin
statements (e.g. CREATE INDEX, CREATE DATABASE) run eagerly, not buffered. The
official Neo4j drivers’ managed transaction functions work as expected, including
automatic retry on the conflict error below.
Isolation: explicit transactions are snapshot-isolated, not serializable
BEGINpins 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 yourBEGINis invisible to your transaction (repeatable reads, no phantoms). Your own buffered writes are visible to your own reads (read-your-own-writes).COMMITis 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, theCOMMITis rejected with a transient error (Neo.TransientError.Transaction.LockClientStopped), which the Neo4j drivers’ managed-transaction functions retry automatically. This prevents lost updates (e.g. two clients that both “read a counter, increment, write it back” — one commits, the other retries against the new value instead of silently clobbering it).- Autocommit (a bare single-statement
RUN) holds the per-database write lock for the statement, so autocommit writes to one database are serialized.What SI does not give you: write skew
Snapshot isolation detects write-write conflicts only. A read-write conflict with disjoint write-sets (write skew) is still possible: two transactions can each read a value the other overwrites, as long as they write different things, and both commit. This is the standard, well-understood SI limitation — it is not serializable and not linearizable. If you need a constraint that spans rows one transaction reads and another writes (e.g. “at most one on-call engineer”), enforce it in a single autocommit statement or with an application-level guard. See Limitations.
Conflict granularity
Property writes conflict at
(entity, property)granularity (two transactions updating different properties of the same node do not falsely conflict); create/delete and label changes conflict at whole-entity granularity. Freshly-created nodes carry new UUIDs, so concurrent inserts never conflict.
Causal consistency (bookmarks)
A standard Neo4j driver session gets read-your-writes automatically: the
driver threads an opaque bookmark from each statement’s result into the next
session.run call, and the server blocks that next read until it has caught up
to the bookmark. This now actually works end-to-end against patinaDB — no client
code change needed, since it’s the driver’s default session behaviour.
Concretely:
- Every successful autocommit write (a bare
RUN) and every successfulCOMMITreturns a bookmark encoding the Raft log index that write was committed at. - A subsequent
BEGINor autocommitRUNcarrying that bookmark blocks until this connection’s node has applied that index, before doing anything else — forBEGIN, before it pins its snapshot-isolation snapshot, so the pinned snapshot itself already reflects the bookmarked write. - Once caught up (the common case, especially against the leader — no wait at all), the statement runs as normal.
This matters most when a routing (neo4j://) driver load-balances a session’s
reads across followers (see cluster-aware
routing below): without a bookmark, a write-then-read
session could land its read on a follower that hasn’t replicated the write yet
and get a stale result with no error. With bookmarks, that same session
either waits briefly for the follower to catch up, or times out loudly — it
never silently returns stale data.
The wait is bounded: --bookmark-wait-timeout-secs (default 10 seconds; see
Configuration Reference) caps how long a node will wait for
its own applied state to catch up to an incoming bookmark. If that bookmark
can’t be reached in time, the client gets a driver-retryable
Neo.TransientError.Transaction.BookmarkTimeout — the driver retries, and its
routing may pick a more caught-up member — never a silent stale read. Set
--bookmark-wait-timeout-secs 0 to disable the wait entirely (bookmarks are
then parsed but ignored, reverting to plain possibly-stale-follower reads).
Read-consistency by surface
Different ways of talking to patinaDB give different freshness guarantees for a read that follows a write:
| Surface | Guarantee |
|---|---|
| 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 flag | Eventually 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, default10): 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. Set0to disable.--bolt-idle-timeout-secs(PATINADB_BOLT_IDLE_TIMEOUT_SECS, default300): 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-secsand--stream-pull-timeout-secsbelow — while waiting for the next client message. Covers a connection that finishes the handshake and/or HELLO/LOGON and then goes silent. The clock resets on every client message, so a well-behaved driver never trips it. Set0to disable.- The
--max-bolt-connectionspermit 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 aNeo.TransientError.General.DatabaseUnavailablefailure (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, default5000000): the maximum number of resolved write operations an explicit transaction may buffer across all its statements beforeCOMMIT. 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 useCALL { … } IN TRANSACTIONS, which commits in bounded chunks). Each individual statement is separately bounded byPATINADB_MAX_CAPTURE_OPS.--idle-tx-timeout-secs(PATINADB_IDLE_TX_TIMEOUT_SECS, default300): 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 abandonedBEGIN(a client that opened a transaction and then went away withoutCOMMIT/ROLLBACK) can’t hold resources forever. The clock resets on every statement, so a slow-but-active transaction is never reaped. Set0to disable the reaper.
Database selection
The target database is taken per-RUN / per-BEGIN (from the Bolt db field or
a USE <db> prefix) and is never sticky across autocommit statements —
connections are pooled, so each statement resolves its own database. See
Multi-Database.
System / introspection shim
On connect, the Neo4j Browser fires admin/introspection statements that are not
ordinary Cypher (CALL dbms.components(), SHOW DATABASES, CALL db.labels(),
routing-table lookups, …). The server recognises these and returns
canned/registry-derived results so the Browser connects cleanly, shows a server
version, and populates its database dropdown. Real procedures (notably
CALL db.index.fulltext.*) are not swallowed by the shim — they reach the
engine and return real data.
Cluster-aware routing
neo4j:// (routing) drivers ask the server for a routing table and then connect
to the addresses it returns. In a cluster, patinaDB answers with the real
topology — writes to the leader, reads spread across the followers — so a
routing driver offloads reads to replicas automatically. See
High Availability → Cluster-aware Bolt routing for the
full WRITE/READ/ROUTE breakdown and the lag-immune clean-tag read pattern. A
single-node server returns itself for all three roles.
Behind a reverse proxy
Behind a TLS-terminating proxy or load balancer, set --advertised-addr to the
public host:port so the routing table sends drivers to a reachable endpoint
(each node advertises its own). For a direct bolt:// connection this isn’t
needed — the default advertises the listen address. See
Authentication & TLS.