Skip to content

feat: Bolt protocol endpoint + Cypher performance campaign#1449

Open
bplatz wants to merge 48 commits into
fix/cypher-benchgraph-gapsfrom
feature/bolt
Open

feat: Bolt protocol endpoint + Cypher performance campaign#1449
bplatz wants to merge 48 commits into
fix/cypher-benchgraph-gapsfrom
feature/bolt

Conversation

@bplatz

@bplatz bplatz commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Adds a Bolt protocol endpoint (Neo4j drivers → openCypher), moves Cypher to bare namespace-0 names by default.

Bolt protocol server

  • fluree-db-bolt protocol crate: PackStream codec + session state machine; listener in fluree-db-server (autocommit + explicit BEGIN/COMMIT/ROLLBACK), Node/Relationship/Path + temporal structures, write-RETURN entities as typed nodes.
  • Session authentication with identity-derived policy enforcement.
  • bolt feature on by default (listener binds only when bolt_listen_addr is set); first-class CLI flags on server run/start/restart; reference + guide docs.

Bare namespace-0 names as the Cypher default

Cypher labels/types/property keys now live under namespace 0 (the empty prefix) instead of being forced under http://example.org/: zero-config durable names for LPG users, planner keeps full Sid-gating, and bare JSON-LD terms converge on the same names. RDF compat is the opt-in via @context/@vocab (ledger default or per-view). Placement rule is locked by test: no colon → whole name verbatim (never namespace-split); colon-containing backticked names are the RDF escape hatch.

Performance campaign

Read/serve path, all query surfaces (SPARQL and JSON-LD ride the same substrate):

  • Shared leaf mmaps through the leaflet cache — leaf files are immutable CAS artifacts; the per-query open+mmap+munmap cycle and whole-leaf fs::read copies were the dominant fixed cost of an indexed point lookup.
  • Cascaded within-leaflet seek: bound point probes binary-search every bound sort-key prefix column (was: leading column only), making them leaflet-size-independent on all four index orders.
  • Galloping batched lookups (SPOT/PSOT/OPST): sorted-merge the wanted-id list against leaflet batches instead of per-row membership tests; chunker gap-split reinstated at the measured break-even (4096 ids).
  • Bolt node-emit hydration: batched sorted SPOT crawl engaged under live novelty with per-subject overlay dirty-split; shared Arc<str> strings through the encode path; adjacency no longer inlined into property maps.
  • Raw-id shortestPath lane: bidirectional BFS in s_id space with batched frontier expansion (one galloping sweep per side per level); dictionary touched only at endpoints + final path. Per-node overlay dirty-split keeps novelty shortcuts/retracts/re-asserts and novelty-only endpoints exact (end-to-end test); views the lane can't serve (policy, unsummarizable overlay) keep the Sid search.
  • Point-lookup floor: parsed-AST cache, reified-edge annotation probe gate, parallel wildcard JSON-LD prefetch.

Fixes surfaced along the way

  • Time-travel over fully retracted predicates regressed by the bound-object driver change: driver selection is rdf:type-first under replay (to_t < index max_t) so probes stay on the replay-correct SPOT sidecar lane. (The underlying rebuild-path hole — omitted empty partitions — predates this branch and remains open.)
  • Narrow K≥2 hydration projections no longer lose their predicate filter to the wildcard flake cache.
  • Specific-value lookups keep seek speed under predicate novelty; fast paths gate on live novelty rather than the monotonic overlay epoch; per-graph property stats carried through incremental indexing.

Validation

  • Full workspace suites green (api 2569 / query+indexer 1538 / server 414; the one recurring failure is the documented raft-liveness flake, passing in isolation).
  • New coverage: NS-0 round-trip + RDF-compat (API view-level and server ledger-level), exotic backticked-name no-split rule, shortestPath novelty semantics (shortcut / base-edge retract / re-assert / novelty-only endpoint), cascade-seek unit tests.
  • Probes for regression tracking: bolt_node_emit_probe, cypher_point_floor_profile (per-phase floor decomposition, PROBE_ONLY/PROBE_USERS filters).

@bplatz bplatz requested review from aaj3f and zonotope July 7, 2026 02:57
bplatz added 28 commits July 7, 2026 07:36
Process-wide bounded LRU (FLUREE_CYPHER_AST_CACHE, default 512 entries)
keyed on statement text, holding the pre-substitution AST. All five
parse sites (read path, write plan, write lowering, RETURN planning)
route through one choke point; params substitute into a per-request
clone, so lowering still sees only concrete literals. Statements over
8 KiB are never cached, keeping unique bulk payloads from evicting the
short hot statements the cache exists for.

Also adds examples/cypher_phase_profile.rs, which times parse / clone /
substitute / lower / end-to-end per statement shape to ground the
lowered-IR-cache follow-up decision (see docs/design/bolt-adapter.md).
Pure, IO-free server-side Bolt implementation: PackStream encode/decode
with hostile-input bounds (length caps, nesting depth), chunked message
assembly (split chunks, NOOP keep-alives, size ceiling), 4-slot version
negotiation (4.4 and 5.0-5.4, range expansion, manifest slots skipped),
typed request/response messages, and the autocommit session state
machine (HELLO/LOGON, RUN via caller-executed Turn::Execute, reactive
PULL/DISCARD batching with has_more, FAILED->IGNORED->RESET recovery,
single-entry ROUTE table). BEGIN answers a clear FAILURE per the
support-matrix contract. See docs/design/bolt-adapter.md.
Feature-gated `bolt` (default off): --bolt-listen-addr /
--bolt-default-db (+ [server.bolt] in the config file) bind a TCP
listener alongside HTTP; each connection drives a fluree-db-bolt
session against the same entry points the HTTP routes use —
query_cypher_with_params for reads, the consensus submit path (with
write-RETURN skolem planning) for writes. RUN dispatches on the new
cypher_statement_is_write (AST-cache-backed).

Result mapping shares the per-binding converter with cypher-json:
format::cypher::table() now exposes the pre-flattening RDF-faithful
cells (QueryResult::to_cypher_table), from which the Bolt glue maps
PackStream values — xsd:decimal to Float (Neo4j parity, noted in the
support matrix), temporal values as ISO strings in v1, node/rel/path
shapes matching the JSON transport.

The unauthenticated v1 listener refuses to start under
data_auth_mode=required. End-to-end tests drive the real listener over
TCP (handshake, 4.4/5.x sessions, params, PULL batching, write
round-trips, FAILURE/RESET recovery); tests/bolt_driver_smoke.py is
the official-python-driver compatibility bar, run manually.
parse 1-4us / lower 1-4us on benchmark-shaped statements: the
lowered-IR cache the Bolt design doc left open is not warranted.
Profiler defaults drop to 500 users / 100 iters with e2e capped at 20
rounds -- aggregate statements over large unindexed novelty run for
minutes and the absolute e2e numbers are novelty-path-only anyway.
RETURN n now yields real Bolt Node structures: element_id = full IRI,
numeric id = stable FNV-1a of the IRI, labels via the engine's
cypher_name_from_iri rule (db:Node marker hidden), and properties
fetched per subject at format time (SPOT scan over snapshot+overlay,
cached per table walk; multi-valued predicates become lists, ref values
stay IRI strings). Relationship values carry endpoints, type, and
annotation properties when reified; paths map to Bolt's unique
node/rel lists + walk indices. xsd:date/dateTime/time map to PackStream
temporal structures, with the 4.4 legacy local-seconds DateTime and
Local variants for timezone-less lexical forms.

The typed surface is QueryResult::to_cypher_typed_table over a new
format::cypher_typed cell model sharing column selection and the
Cypher naming rule (fluree_db_query::eval::cypher_name_from_iri, now
pub) with the JSON formatter. Hydration errors under a view policy
rather than bypassing per-flake filtering.
Optimistic model, one commit per statement, atomic publish
(fluree-db-api/src/cypher_txn.rs): BEGIN pins the cached head; each
write statement lowers and stages against the transaction's private
state (conditional MERGE probes included), builds a real commit via
the pure build_commit, and advances the private state with
StagedCommit::finalize_state — reads inside the transaction get
read-your-writes, statement errors surface at RUN time and poison the
transaction until RESET. COMMIT takes the ledger write lock, verifies
the head is still the pinned base (t + commit id), writes the pending
blobs (content-addressed, idempotent) and advances the head ref once:
intermediate states are never observable while the commit chain keeps
per-statement provenance. A moved base fails the whole transaction
with CommitConflict, surfaced as Neo.TransientError.* so official
drivers' managed transaction functions (execute_write/execute_query)
retry — isolation is serializable-against-base, stronger than Neo4j's
read-committed. Single-node deployments only: Raft/peer reject BEGIN.

Session state machine grows TxReady/TxStreaming with qid-addressed
result buffers (multiple open results per transaction), Turn::Begin/
Commit/Rollback/Reset lifecycle actions, and commit bookmarks
(fluree:t:<t>). RESET now signals the glue so server-side transaction
state drops with it.
Runtime config is the real gate — the listener binds only when
bolt_listen_addr is set — and a default-off build feature meant release
binaries could not speak Bolt at all. The feature stays declared for
minimal builds.
docs/design/bolt-adapter.md was an implementation hand-off — sequencing,
estimates, pre-implementation anchors, since-resolved questions — not
durable knowledge. What lasts now lives where users and developers look:
docs/api/bolt.md (protocol surface, value mappings, transaction
semantics, implementation pointers) and docs/guides/bolt.md (enable,
configure, driver examples, retry contract, troubleshooting). The
transaction commit model stays documented at the code in cypher_txn.rs;
code comments and the profiler example no longer cite the deleted doc.
--bolt-listen-addr / --bolt-default-db join the CLI's first-class
server flags (previously reachable only via -- passthrough or the
config file): threaded through foreground and background starts,
preserved across restart via the child-arg merge, shown by --dry-run
and by 'fluree server status' (recorded in server.meta.json). Child
args skip the bolt flags when the user already passed them via --
passthrough so the child's arg parse never sees duplicates.
The typed table fetched each distinct node's properties with one
awaited SPOT scan during the row walk — serial point reads that made
data-heavy Bolt results 1.5-7x slower than the (non-hydrating) JSON
transport. The engine has already produced the subject list by format
time, so the fetches are independent: a prefetch pass now collects
every hydration subject in the result (node refs, relationship
reifiers, path nodes), sorts by subject for leaflet locality, and
issues the reads with bounded concurrency (16) into a shared raw-flake
cache; the row walk then renders from cache. Encoded bindings that
materialize during the walk fall back to the single-fetch path
unchanged.
CREATE ... RETURN n over Bolt returned the created entity as a bare
skolem-id string while reads returned Node structures. The new
write_return_typed_rows reconstructs the created-entity Sids from the
skolem id (shared solution-count probe factored out of the JSON
envelope path, which the HTTP route keeps unchanged) and hydrates them
against the post-commit state through the same batched prefetch reads
use — labels and properties included. Both the autocommit path and
explicit transactions (CypherTxnWriteOutcome now carries the typed
table) return them; RETURN of write expressions (n.id) remains a
documented, cleanly-rejected v1 limit.

Also resolves an unused-binding clippy failure that arrived with the
rebased it_query_cypher.rs seek test — it would have failed CI's
all-targets lint.
The select-map hydration path fetched each row's subject with one
awaited SPOT scan — the same serial shape the Bolt typed table had.
run_hydration_rows now bulk-fetches the top-level subjects of wildcard
hydration columns before the row walk (sorted by subject, 16-way
bounded concurrency) into a flake cache on HydrationCaches shared
across rows, levels, and nested ref expansion; the expansion body reads
through it. Prefetch and read-through route via
fetch_subject_properties, so policy filtering and fuel charging are
unchanged. Single-ledger only: dataset hydration routes roots to
home-ledger views whose Sids share an encoding space, so it keeps the
per-fetch path; explicit projections keep their narrowed reads.
The 625813c prefetch never fired on benchmark-shaped results: bare
node vars come off the binary scan as EncodedSid bindings, which the
subject collector skipped — every row silently fell back to the serial
fetch, which is why data-heavy queries didn't move. The collector now
materializes encoded subject refs (via a new graph-direct
materialize_with_graph) before collecting.

Profiling the engaged prefetch showed the second half of the story:
the per-subject fetches are CPU-bound in-memory work (~18us of cursor
setup + leaflet decode + dict resolves each), so buffer_unordered's
cooperative concurrency ran them on one core. The prefetch now spawns
chunked tasks across runtime workers. Also memoizes per-flake
predicate-key and class-label decodes across the result (predicates
repeat on every node).

Local probe (200 rich nodes, indexed, RETURN n, release): typed table
4.0ms -> 2.1ms; prefetch 3.5ms -> 1.4ms. Probe kept as
examples/bolt_node_emit_probe.rs. Remaining floor is the ~18us
per-subject fetch itself — cutting that needs an engine-level batched
leaflet pass (one sorted cursor sweep for many subjects), noted as the
next lever.
Bench-verified root cause of the remaining RETURN n gap: node emit
rendered every ref-valued flake as an IRI-string property, so each
node dragged its full adjacency — one dict/arena IRI materialization
per edge target server-side (literal objects are already inline in the
leaflet) plus degree-scaled wire and driver-decode cost. A Neo4j Node
carries scalar properties only; relationships surface by binding an
edge or path var. Node and annotation property maps now skip Ref
flakes.

Probe (200 nodes x 10 edges, release): typed table 2.86 -> 2.35 ms
server-side; the larger share of the bench win is the ~10 IRI strings
per node no longer serialized and rebuilt by the driver. The SPOT
probes still read ref flakes (unchanged, per design) — cutting that is
the engine-level batched-sweep lever.
The per-subject point reads were the remaining node-emit floor
(~18us each: cursor setup, leaflet re-decode, per-call overhead). The
prefetch now has two lanes. Subjects that arrive with a raw s_id
(EncodedSid off the binary scan) go through the existing gap-aware
batched crawl (batched_lookup_subject_properties: sorted s_ids, one
SPOT cursor sweep per contiguous chunk, each touched leaflet decoded
once) and build nodes straight from (p_id, o_type, o_key) rows —
ref-valued rows are skipped by o_type before any decode, so edge
adjacency never touches the dictionary at all. The lane is gated on
OverlayProvider::is_effectively_empty; live novelty, reifiers, path
nodes, and Sid-only subjects keep the spawn-parallel per-subject
fallback, so correctness never depends on the fast lane.

Probe (200 rich nodes x 10 edges, indexed, release): typed table
2.35 -> 0.66 ms (~3.3us/node; 6x from the 4.0ms serial start). The
probe now self-checks hydrated content, and a new wire-level
integration test covers the batched lane on an indexed ledger
(labels, scalars, Date structure, no-adjacency rule).
The bench showed ee4b597 flat at every result size — the batched
lane never opened. Two compounding gates kept it shut whenever any
write had landed since the last reindex: (1) the lane required
OverlayProvider::is_effectively_empty, which is ledger-global, and
(2) under live novelty the executor emits materialized Sid bindings
rather than EncodedSid, so no subject carried the raw s_id the lane
required. Mixed read/write suites therefore always took the
per-subject fallback — identical numbers to the pre-batching code.

The gate is now per-subject: one overlay SPOT walk collects the
dirty-subject set, cached process-wide keyed on
(content_version, g_id) — the contract content_version exists for.
Clean subjects take the batched sweep even when their binding has no
raw s_id (one reverse dict lookup, ~2us, versus an ~18us fallback
read); dirty and novelty-only subjects keep the merge-correct
fallback. A tracing::debug! reports the per-query lane split.

Probe now covers the live-novelty regime: typed table 2.93 -> 0.65
ms/iter (identical to clean HEAD; 199 batched / 2 fallback), with
correctness assertions that a novelty-touched subject renders merged
truth, an untouched subject stays exact, and a novelty-only subject
appears.
…ement

Bolt now authenticates against the same identity pipeline as the HTTP
data plane and enforces data policy from the verified identity.

Auth (fluree-db-bolt + server glue):
- HELLO (<=5.0) / LOGON (5.1+) auth maps surface as Turn::Authenticate;
  verification is async in the glue via the hoisted transport-agnostic
  verify_data_principal (the HTTP bearer extractor now wraps the same
  function). Schemes: bearer (data-plane JWT/JWS), basic as a token
  carrier (password holds the token, principal ignored), none.
- data_auth_mode enforced per session (none ignores credentials,
  optional verifies when presented, required refuses anonymous),
  replacing the refuse-to-start startup guard.
- Per-statement re-checks for long-lived sessions: token expiry
  (Neo.ClientError.Security.TokenExpired -> drivers re-auth) and
  ledger read/write scopes (DatabaseNotFound, existence-hiding like
  the HTTP routes' 404). LOGOFF drops the identity; RESET after a
  failed LOGON returns to the authentication state.

Policy:
- Reads (autocommit + inside explicit transactions) wrap the view with
  the session identity's in-ledger policy bindings, mirroring the HTTP
  Cypher route. No policy knobs over Bolt by design: the transport
  only authenticates; policy derives from the graph.
- Typed-table node hydration is now policy-aware instead of failing
  closed: each raw-SPOT subject fetch filters through the view's
  QueryPolicyEnforcer (bulk class-cache populate + per-flake filter),
  the same enforcer the scan path uses.
- Writes: identity flows into GovernanceOptions (f:modify enforcement
  via the consensus committer) and CommitOpts.identity (f:identity
  provenance). Explicit transactions pin governance at BEGIN, build
  the policy context against the transaction's private state per
  statement, and policy-wrap conditional MERGE probes.

Tests: session-machine auth coverage in fluree-db-bolt; new
bolt_auth_integration.rs covers required/optional modes, both
protocol generations, basic-as-token-carrier, scope gating (autocommit
and explicit transactions), per-statement expiry, LOGOFF re-auth, and
two identities seeing different graphs from the same Cypher; api-level
test pins typed-table hydration filtering under policy.
pattern_long/pattern_cycle ran 2.2-2.8x Neo4j because every value-only
bound relationship variable added a per-hop OPTIONAL annotation probe,
gated only on f:reifiesSubject being in the dictionary — which the
built-in vocab satisfies on effectively every ledger, so the probes
always ran, annotations or not.

The gate is now evidence-based with three conservative tiers:
dictionary absence (certain no), index property stats showing
f:reifiesSubject facts (per-snapshot, sync), and a cached overlay PSOT
walk answering whether novelty carries any reifies fact (keyed on
content_version; callers that cannot supply the overlay stay
conservative). LoweringContext grows reified_edges_possible (default
true); the read path, query_cypher_ast, and the conditional-write
probes supply their view's overlay, explain stays conservative.
Property-reading edge vars keep annotation-only lowering unchanged.

Probe (200 users, indexed, no annotations): 2-hop bound-edge pattern
3.04 -> 0.54 ms (~5.6x).

The new gate-tier integration test also exposed a pre-existing Bolt
mapping gap: a reified edge binds its variable to the annotation
subject, which the typed table rendered as a Node. Subject cells are
now reifier-aware in both hydration lanes — f:reifies* rows make a
Relationship (endpoints + type from the reified triple, reifier IRI as
element_id, annotation scalars as properties); the tier test pins a
novelty-only annotation binding through the reopened gate.
shortestPath ran one-to-two awaited range reads per node per hop
(cursor setup + overlay merge per call), which is why it sat 7-15x
behind Memgraph and scaled with graph size. Both search modes
(bidirectional Single, layered allShortestPaths) now expand whole
frontier levels through a new frontier module: base edges come from
the gap-aware batched index sweeps (PSOT refs for typed hops, SPOT
rows for wildcard, OPST inbound for the reverse side), and novelty is
applied as an edge delta built from ONE netted overlay walk (cached on
content_version) — asserts extend the expansion, retractions mask base
rows, so the fast path is merge-correct rather than gated. Traversal
state is PathNode (raw u64 subject ids for persisted nodes, Sids only
for novelty-only nodes), cutting per-visit hashing and clone cost;
Sids materialize only for emitted paths.

Also unifies the two hardcoded traversal caps behind
FLUREE_PATH_MAX_VISITED (read once, default raised 10k/100k -> 1M),
which unblocks wide traversals like benchgraph expansion_4 at 100k+
nodes; the cap doc states the memory tradeoff.

Probe (200 nodes, wildcard *..15): 1.52 -> 0.54 ms/iter; the win grows
with scale since per-node overhead multiplied by visited count is what
it removes. New regression test pins the delta semantics: a
novelty-added shortcut wins over the indexed route, and retracted
indexed edges stop being traversable.
bplatz added 17 commits July 7, 2026 07:36
The level expansion materialized a whole level's (parent, neighbor)
pairs before insertion — frontier x out-degree x 48B, which at the 1M
visited cap on a degree-30 graph is ~1.4GB transient. expand_with now
processes the frontier in 8192-node slices and streams edges through a
visitor (Break stops a level early, e.g. a bidirectional meet), so
peak transient memory is slice x degree (~15MB) regardless of the cap.
The bidirectional side maps now carry (predecessor, depth) so the
min_hops check at a frontier meet needs no mid-stream reconstruction.
The level-batched shortestPath frontier regressed 4-5x on the real
benchmark graph (pokec medium: 4.77 -> 23ms) while winning on the toy
probe. The probe was doubly unrepresentative: clustered subject ids
(no scan amplification) and, once made scattered, an unreachable
endpoint pair (exhaustive search, where batching wins). The bench
measures reachable pairs, where a bidirectional search meets after
touching a few hundred nodes — an early-terminating search must not
read whole levels ahead of the meet, at any batch size. Lesson
recorded: batched level expansion fits exhaustive traversals
(expansion_4-style reachability — already at Memgraph parity on
per-node reads); per-node point reads fit early-exit searches.
shortest_path.rs returns to the proven implementation (0.375 ms on a
bench-shaped reachable pair vs 8-49ms for the batched variants); the
frontier module is removed with it (git keeps it for a future
property-path attempt measured against representative shapes).

Kept, both bench-confirmed or latent-bug fixes:
- visited-cap unification behind FLUREE_PATH_MAX_VISITED (expansion_4
  completes at medium, dead even with Memgraph, rows exact);
- gap-aware chunking in the batched lookups (scattered subject sets
  become near-point-seeks instead of dragging one scan across the id
  space between them);
- per-chunk membership filtering in all three batched lookups: cursor
  batches are leaflet-granular and spill past a chunk's range, and the
  global membership set collected spillover rows twice — duplicated
  rows for boundary subjects (surfaced as doubled node properties at
  >1000-subject hydrations; latent for the stats consumers).

The probe seeds a scattered 20k-node graph (multiplicative-hash edges)
with a reachable shortestPath pair so future traversal work measures
against representative shapes.
Node hydration allocated a fresh String per property key, label, and
IRI per node — despite the hydrator already memoizing every name as
Arc<str> — and then the PackStream encoder copied each one again into
Value::String. On a 20k-node RETURN n that was ~180k redundant
allocations per result.

- CypherNode/CypherRelationship/CypherPath carry Arc<str> for iris,
  labels, property keys, and type names; the hydrator's memoized names
  now flow through by refcount instead of by copy.
- The Bolt codec's Value::String and MapValue keys hold Arc<str>, so
  node/relationship/path structures move the shared strings straight
  onto the wire encoder — the string is allocated once per distinct
  name per result, not once per use.
- IriCompactor::decode_sid_shared: decode to Arc<str>, returning the
  Sid's own name (refcount bump, zero work) for the empty and overflow
  namespaces and for any namespace whose prefix is empty — the hot
  path if Cypher identifiers move to namespace 0.

Probe (20k nodes, 8 props): typed hydration 87 -> 73 ms/iter; the
server-side PackStream encode additionally stops copying every
key/label/IRI per node.
Cypher labels, relationship types, and property keys now resolve to
bare names in namespace 0 (the pre-registered empty prefix) instead of
being forced under http://example.org/. Cypher users get durable,
readable identifiers with zero configuration; the planner keeps full
Sid-gating (stats lookups, batched join lanes) because bare names
encode to Sid(0, name) at lowering on both read and write paths.

RDF compatibility is now the opt-in: a ledger default context or
per-view context with @vocab restores IRI-prefixed resolution, covered
by new API-level and server-level tests. Bare JSON-LD terms land in
the same namespace, so Cypher and JSON-LD converge on identical names.

Bare-name lowering only fires when no vocab is configured and the
identifier contains no colon, so scheme-ful strings (rdf:type,
backticked IRIs) keep strict encode semantics. Policy identities are
unaffected: identity resolution uses strict encoding, so identities
remain scheme-ful IRIs/DIDs.
Gap-aware chunking (split at subject-id gaps > 64) shattered scattered
hydration sets into near-point-seeks — hundreds of ~8 us cursor
descents per node-emit crawl. The benchmark it was meant to help
measured it as a ~2x per-row regression instead (pokec node-emit 32 ->
61-126 us/row), and its original beneficiary (the batched shortestPath
frontier) is already reverted. Span/size-bounded chunking returns for
all three lookups; the per-chunk membership filtering stays — chunk
boundaries can still share a leaflet, so global-set filtering would
double-collect spillover rows.
The wildcard subject-flake cache arm (5717392) matched on
'anything but K=0/K=1', so narrow K>=2 explicit projections fetched
every predicate unfiltered — silently paying the dict-touch and
per-row fuel the predicate_filter exists to save. The cache
read-through now applies to true wildcard levels only; narrowed
projections keep their filtered SPOT reads.
End-to-end proof that colon-free backticked names (slash, hash, space,
embedded @) round-trip whole through write and read — canonical_split
only splits when a colon is present, so no namespace is ever minted
for them — while colon-containing backticked names take the RDF
escape hatch (namespace split + registration) and still round-trip.
Document the placement rule in the Cypher names section.
Cursor batches are leaflet-granular, and the batched subject-property
lookup tested every row against a membership set — a single-node
hydration paid a full-leaflet scan (~16k contains-checks, ~120 us) to
collect one subject's ~20 rows. SPOT's primary sort key is s_id, so
the consumer now sorted-merges the wanted id list against the batch
with galloping binary search, visiting only each subject's contiguous
run. Spillover rows for a neighboring chunk's subjects are excluded by
construction, preserving the no-double-collect invariant the
membership set provided.

Single-node typed hydration drops 135 -> 34 us (in-process
single_vertex_read 0.24 -> 0.14 ms); scattered mid-size sets (the
node-emit shape) skip the same per-leaflet full scans. Dense
whole-graph hydrations are unchanged (decode/build dominates).
PSOT/OPST lookups keep the membership set — s_id only ascends within
a predicate/object run there, so the merge precondition doesn't hold.

New examples/cypher_point_floor_profile.rs measures the fixed
per-query floor (parse/plan/exec/format) on indexed point lookups.
Every cursor open paid an open+mmap(+munmap) syscall cycle per leaf,
and the batched-star SPOT walk and group-count fast paths read whole
leaf files into fresh Vecs per query — together the dominant fixed
cost of an indexed point lookup (sample profile: __open/read/stat were
the top real frames under exec).

Leaf files are immutable content-addressed CAS artifacts, so the
mapping is now created once and shared via a LeafMmap entry in the
existing moka leaflet cache (single-flight, error-transparent, keyed
by xxh3(leaf CID)). Mmap does not retain the file descriptor, so
cached mappings cost address space, not fds; the weigher counts
mappings at len/64 (file-backed reclaimable pages, not heap) which
bounds total mapped space at 64x the cache budget. Whole-leaf readers
switch to get_leaf_bytes_shared, which serves the mapping for local
leaves and falls back to the owned read for remote ones.

Indexed floor queries (release, in-process, 20k users): single-vertex
0.22 -> 0.07 ms, bound-property scalar 0.13 -> 0.08 ms, 2-hop bound
edge 0.67 -> 0.44 ms, shortestPath 0.40 -> 0.25 ms. Combined with the
galloping batched crawl, the fixed per-query floor is roughly halved.
…efix

filter_batch binary-searched only the LEADING bound sort column, so an
OPST bound-object point probe (o_type pinned = every ref row) still
linear-scanned nearly the whole leaflet per probe, and SPOT s+p probes
scanned the subject's full row run. The seek now cascades: within the
rows equal on key column k, rows are sorted by key column k+1, so each
successively bound filter column narrows by two more partition_points
(Const columns pass through or prove the range empty). Rows outside
the range differ on a bound prefix column, so output is identical to
the full scan; the per-row filter still validates the range.

This makes bound point probes leaflet-size-independent on every index
order and every query surface. Indexed floor (release, in-process,
20k users): single-vertex 0.072 -> 0.027 ms, scalar prop 0.082 ->
0.039 ms, shortestPath 0.25 -> 0.085 ms (200k users: 138 -> 77 ms —
per-node probes are the shortestPath cost model, so it gains
everywhere the search touches). The floor profiler gains a
shortest_path statement and a PROBE_ONLY filter.
The bidirectional BFS paid Sid-space costs at every visited node: a
range_with_overlay probe (cursor + full Flake/Sid materialization),
string-hashed visited maps, and a dictionary re-encode per probe. At
bench scale that made shortestPath the widest remaining gap (18x at
large), scaling with visited-set size.

The search now runs in raw s_id space when the view allows it (binary
store present, no active policy, summarizable overlay): frontier
levels expand through the batched lookups — one galloping sweep per
side per level instead of a probe per node, neighbors taken directly
from IRI_REF o_keys — and state lives in FxHashMaps keyed by u64. The
dictionary is touched only at the endpoints and to materialize the
final path.

Overlay correctness is per-node: subjects/ref-objects the overlay
touches (retracts stamp both sides) are collected once per overlay
version into id-space dirty sets (LRU-cached), and dirty or
novelty-only nodes expand through the existing Sid fallback, so
novelty shortcuts, novelty retracts of base edges, re-asserted edges,
and novelty-only endpoints all resolve exactly (new end-to-end test).
Views the lane can't serve keep the Sid search unchanged.

Supporting changes in the batched lookups: the galloping merge now
covers all three (PSOT rows are s_id-sorted after the cursor's p_id
filter pins the leading key; OPST gallops o_key inside the
binary-searched IRI_REF o_type run), and gap-splitting returns to the
chunker at the measured break-even (4096 ids) — wide-gap scattered
sets stop sweeping every leaflet in their span, while dense sets keep
long shared scans (node-emit unchanged, ~62 ms/20k dense rows).

shortestPath (release, in-process): 20k users 0.085 ms (parity with
the per-node lane), 200k users 138 -> 6.4 ms.
Comments and test fixtures referenced external benchmark workloads and
their query names; describe the query shapes on their own terms
instead. Protocol-required compatibility references (driver agent
string, wire-format parity, spec links) are unchanged.
Bare `MATCH (n) RETURN count(n), count(n.age)` and the min/max/avg/sum
family folded from index directories only at HEAD; any uncommitted
novelty made the whole operator decline to a full-graph flake scan
(linear in graph size — seconds-to-minutes for a single count once a
large indexed ledger carried post-index writes).

Add an overlay lane to fast_whole_graph_agg: keep the O(directory) base
counts and reconcile them with a bounded O(novelty) pass. The subject
universe is the base SPOT lead-group count plus the overlay's net subject
delta (per touched subject: a surviving assertion means it exists; new
vs base-present decides +1/0; a retraction-only base-present subject is a
possible deletion and declines to the exact pipeline). Each
predicate-scoped fold (rows, distinct subjects/objects, min/max/avg/sum)
runs an overlay-merging cursor, linear in the predicate's rows rather
than the graph's. Covers the whole-graph anchor's scalar folds; class
anchors and histograms under novelty still decline.

In-process (dirty indexed ledger): count(n),count(n.age) 200k 1744ms ->
0.5ms; count(n) 1562ms -> 0.02ms; holds at 1M base (2.3ms) and 5000-flake
novelty (9ms). Results verified exact against the WITH-barrier pipeline
across new/updated/deleted subjects.

Also fixes a clippy semicolon lint in batched_lookup for_each_subject_run.
bplatz added 3 commits July 7, 2026 08:51
`MATCH p = shortestPath(...) WHERE all(x IN nodes(p) WHERE <pred>)` was
post-filtered: Fluree found the unconstrained shortest path, then checked
the predicate, returning EMPTY whenever that single path violated it.
Neo4j/openCypher evaluate the predicate during the search and return the
shortest path whose nodes all qualify — so on any graph with a longer
qualifying route Fluree gave a wrong (empty) answer.

Absorb a trailing `all(x IN nodes(p) WHERE …)` filter into the
ShortestPath pattern (new `node_filter`) at lower time when the predicate
references only the iteration var, and evaluate it per node during the
Sid-lane bidirectional BFS: endpoints must qualify and only qualifying
nodes enter the frontiers, so the search returns the shortest qualifying
path. Multiple such filters AND-combine. The raw-id lane declines when a
node filter is present (it never materializes a node Sid). Qualification
reuses the post-filter's member resolution (eval_single_node_predicate),
so a pushed check is identical to the original `all(...)`.

Correctness only; the filtered case runs the slower Sid lane (batched
raw-id filtering to close the perf gap is a follow-up).
The batched subject probe read every leaflet spanned by the probe set's
[min_subject, max_subject] key range, decoding all columns and walking the
predicate run before discarding leaflets that held none of the wanted
subjects. For a hash-scattered neighbor set (e.g. multi-hop Cypher
expansion) that range covers nearly the whole predicate partition, so the
probe decoded hundreds of leaflets to match a handful of subjects.

Add a directory-level skip: for a predicate-homogeneous leaflet the
first_key/last_key bound its subject interval, so a leaflet holding no
wanted subject is declined before the column decode. Also short-circuit
the p-run linear scan on homogeneous leaflets, whose run is the whole
leaflet by definition. Both guard on p_const == Some(p_id); the subject
skip additionally excludes history replay.

expansion_1 2.52ms -> 0.073ms, expansion_1_filter 3.30ms -> 0.076ms,
pattern_long 1.18ms -> 0.131ms on a 200k-user indexed knows-graph;
results unchanged, full cypher/query/sparql suites green.
The per-graph-stats shortcut for COUNT(DISTINCT ?p) was disabled because
the incremental index build persisted delta-only per-graph property stats
(base entries dropped, net-zero churn pinned at count 0), making the
in-memory count wrong after any incremental index. That indexer defect is
now fixed — the incremental build carries base per-graph property stats
forward — and covered by regression tests, so the shortcut is exact again.

Restore distinct_predicates_from_graph_stats in the DistinctPosition::
Predicates arm, keeping the PSOT p_const scan as the fallback when stats
are absent (older index). The scan-hidden-predicate gate above the arm is
unchanged, so the shortcut only runs when its count equals the scan.

Recovers the fast lane on the number-of-predicates query (few predicates:
~0.5ms vs ~1.4ms general distinct scan). New regression test drives the
delta-only trigger without a reifier so the shortcut arm is actually
exercised, and pins its count to the general pipeline.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant