Skip to content

fix: Cypher field-report round — reindex OOM, EXPLAIN, =~, IRI round-tripping, HTTP bulk import, arena sealing#1518

Open
bplatz wants to merge 11 commits into
mainfrom
fix/cypher-kb-feedback
Open

fix: Cypher field-report round — reindex OOM, EXPLAIN, =~, IRI round-tripping, HTTP bulk import, arena sealing#1518
bplatz wants to merge 11 commits into
mainfrom
fix/cypher-kb-feedback

Conversation

@bplatz

@bplatz bplatz commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Addresses the full KB-on-FlureeDB field report (Cypher/Bolt integration): the reindex-query OOM, interactive-latency tooling, surface gaps, the silent write failure, and the bulk-import sidecar.

Performance: reindex-query OOM / 35s (the headline)

UNWIND $uris MATCH (s:INDEXED {value: uri})-[p]->(o:INDEXED) against ~72k nodes / ~190k reified edges pegged CPU and OOM-killed a 16 GB server; LIMIT 20 still took 35s.

Root cause: a value-only relationship binding lowers to OPTIONAL { EdgeAnnotation }; on reified ledgers without a sealed annotation arena, the expanded chain evaluated per row and drove from f:reifiesPredicate — enumerating ~(sidecar / #relationship-types) candidate reifiers per row and existence-checking each with its own scan (~25ms/row).

  • Batched annotation OPTIONAL probe: answers each required batch with three set-wise f:reifies* planned scans (overlay-merged, policy-filtered) hash-indexed once per query run, then per-row lookups. Falls back to the per-row path for history/dataset/multi-ledger contexts.
  • Verified at the reported scale: full 54,600-row result 35s → 1.1s, LIMIT 20 0.9s, flat memory.
  • Supporting: the OPTIONAL hash-join lane admits single-source DefaultGraphSource chains; the forward-arena probe accepts per-row (variable) predicates; structural selectivities for f:reifies* bound-object lookups; a reusable cypher_unwind_probe example pins the shape.

Annotation arena sealing (write-only ingest trap)

Annotated insert + index left annotation_index = None permanently — three cooperating defects:

  1. The attachment provider returned "delta unknown" when the ledger wasn't resident in the LedgerManager (the norm for write-only ingest); the build defensively dropped the arena and stamped the sticky bit, blocking the bootstrap scan forever. Fixed with a transient (never-cached) backend load whose commit replay yields the complete event history.
  2. A full rebuild under Augment coverage discarded a previously-sealed arena; root assembly now receives the previous root's CID and merges the prior arena's events with the delta (the incremental Phase 3d contract).
  3. Fluree::reindex read the NsRecord before quiescing the background indexer, so a racing publish left it rebuilding from a stale record. Re-fetched after cancel + wait-for-idle.

Pinned by a four-cell seal matrix including a background-race reproduction; the deliberate post-defensive-drop scan-fallback contract is unchanged.

Server-side bulk import over HTTP (drops the customer's sidecar)

The negotiated /import-upload handshake now accepts raw source data — the same formats as fluree create --from (TTL/NT/NQ/TriG/JSON-LD/JSONL±gz/zst, plus CSV and Cypher dumps converted server-side via shared fluree-db-api::import_source helpers the CLI also uses). Discovery advertises source-upload + formats; refused on Raft-replicated servers (the pipeline publishes nameservice heads outside the replicated log). fluree create <ledger> --remote <name> --from data.ttl|dump.cypher rides mint→PUT→complete→poll. Imports with annotations get a follow-up reindex so the arena is sealed on arrival.

Also surfaced and fixed: the import commit paths held tracing EnteredSpan guards across store awaits, making the pipeline future !Send.

Cypher surface

  • EXPLAIN: POST /explain/{ledger} with application/cypher (raw or {cypher, params} envelope; $param substitution matches execution), plus fluree query --cypher --explain locally and over --remote. Verified live end-to-end.
  • =~ regex operator: whole-string Neo4j semantics (non-capturing-group anchoring so alternations and runtime patterns behave; inline flags pass through), wired in read filters and write-path WHERE.
  • Round-trippable names: labels()/type()/keys() and rendered node/relationship names now follow the db.labels() rule — strip the configured @vocab, otherwise return the full IRI. A relationship typed http://…/kb#order no longer comes back as "order".

Correctness: silent no-op on wrong Content-Type

A Cypher envelope posted as application/json fell through to the JSON-LD update parser, produced a zero-clause transaction, and returned HTTP 200 with a real commit envelope. Updates with neither insert nor delete are now rejected (422), and a misrouted {"cypher": …} body gets a 400 pointing at Content-Type: application/cypher.

Docs

docs/api/endpoints.md (Cypher explain, source uploads), docs/query/cypher.md (explain, =~, naming rule), docs/cli/server-integration.md (remote Cypher explain + source-upload contracts), docs/cli/create.md.

Not addressed (deliberate)

  • Multi-clause MERGE composition — real architectural constraint (one snapshot horizon per transaction); sequential statements remain the documented workaround.
  • The reported ~18s topic-page latency — needs field profiling; the relationship-binding fix plus arena sealing likely covers most of it, and Cypher EXPLAIN now exists to attribute the rest.

bplatz added 11 commits July 18, 2026 07:41
…lopes

An update with neither an insert nor a delete clause can never change data,
yet it previously parsed to an empty Txn, committed zero flakes, and
returned HTTP 200 with a real commit envelope. A Cypher write posted with
Content-Type: application/json fell through to this path and looked
committed while writing nothing.

- parse_update now rejects bodies without an insert or delete clause,
  mirroring the existing empty-insert/upsert guards. Explicit "delete": []
  keeps its documented no-op semantics.
- The server transact choke point detects a Cypher envelope sent with a
  JSON Content-Type and returns a 400 pointing at application/cypher.
explain_cypher existed in fluree-db-api (sharing the SPARQL/JSON-LD plan
machinery) but nothing routed to it. Now:

- explain_cypher accepts the $param map and substitutes before lowering,
  so the reported plan matches what execution would run; Fluree gains an
  explain_cypher method mirroring explain_sparql.
- The ledger-scoped /explain endpoint handles Content-Type:
  application/cypher (raw text or {cypher, params} envelope), with the
  same bearer-scope and Min-T handling as the Cypher query path. The
  connection-scoped /explain returns 400 pointing at the ledger-scoped
  route, matching the query endpoints.
- fluree query --cypher --explain works locally and remotely (new
  RemoteLedgerClient::explain_cypher); the --at rejection now also
  covers remote explain.
Lexes =~, parses it at string-predicate precedence (STARTS WITH /
CONTAINS level), and lowers to the engine's REGEX — which already exists
for SPARQL — wrapping the pattern as CONCAT("^(?:", pat, ")$") so
Cypher's whole-string match semantics hold, alternations stay grouped,
and runtime (non-literal) patterns anchor too. Wired through both the
read path and the write-path filter lowering. Inline flags like (?i)
pass through to the regex engine.
cypher_name_from_iri blindly stripped everything before the last # or /,
so a relationship typed http://other.example/kb#order came back as
"order" — unparseable as an IRI on the client, and inconsistent with
db.labels()/db.relationshipTypes(), which are @vocab-aware and keep
foreign IRIs whole.

Naming now follows the db.labels() rule everywhere: strip the ledger
context's @vocab prefix when it applies, otherwise return the full IRI.
The vocab rides from Cypher lowering on Query::cypher_vocab into
ExecutionContext for scalar evaluation, and the typed formatter derives
it from the view's default context, so labels()/type()/keys() and
rendered node/relationship names agree. Default-mode (namespace-0 bare
names) output is unchanged.
Reproduces the KB Elasticsearch-reindex query shape — UNWIND over a URI
list feeding a property-equality match plus one-hop expansion against an
n10s-style ledger — with knobs for ledger construction (reindex / bulk
import / novelty-only), a mixed string/langString value predicate, and
reified (annotated) edges. Timings at the reported 72k-node/190k-edge,
21k-URI scale:

- reindex- and import-built roots, plain edges: ~0.4s (fast)
- mixed value datatypes: point lookup degrades 0.1ms -> ~7ms (the
  untyped-string bound-object broad scan; UNWIND lane unaffected)
- annotated edges: the query wedges at 100% CPU (reproduces the field
  report; per-row edge-annotation probe under investigation)
A Cypher relationship variable used only for its value surface
(RETURN p, type(p), ...) lowers to OPTIONAL { EdgeAnnotation } so
reified parallel edges bind per annotation. On a ledger with reified
edges that OPTIONAL evaluated its expanded chain per row, and with no
stats for the system f:reifies* predicates the chain drove from
f:reifiesPredicate — per row it enumerated ~(sidecar / #relationship
types) candidate reifiers and existence-checked each with its own scan.
Measured: 13s for a 520-row UNWIND over 13k reified edges (~25ms/row),
matching a field report of 35s-with-LIMIT-20 / OOM at 21k rows.

- AnnotationValueOptionalBuilder answers the whole required batch with
  three set-wise f:reifies* scans (planned scans: overlay-merged,
  policy-filtered) plus hash lookups per row; falls back to the generic
  per-row path for history/dataset/multi-ledger contexts. 520-row probe:
  13.2s -> 0.04s.
- The batched OPTIONAL hash-join lane now admits single-source
  DefaultGraphSource chains (the edge-annotation expansion wrapper).
- The forward-arena probe accepts a per-row (variable) predicate, so
  untyped -[p]-> also qualifies where the arena exists.
- estimate_triple_row_count gets structural selectivities for bound-
  object f:reifies* lookups (stats never cover system predicates).
- cypher_unwind_probe example grows annotated/mixed/import modes and
  query variants that isolate the relationship-binding cost.
The batched annotation OPTIONAL probe re-drained the three f:reifies*
predicates for every required batch — ~55 sidecar passes on a 54k-row
result, ~35s at 190k reified edges. The builder lives for exactly one
query run against one snapshot/overlay/to_t, so the maps are built once
on first use and reused.
…lementers

fluree query --cypher --explain --remote now POSTs application/cypher to
the ledger-scoped /explain endpoint (verbatim body, params substituted
server-side); drop the stale 'explain not supported for Cypher' claim.
The negotiated /import-upload handshake now accepts raw source data —
the same formats fluree create --from ingests locally — and runs the
chunked bulk-import pipeline on complete instead of a .flpack restore.
Mint takes source_kind: "source" plus filename (its extension drives
the pipeline's format sniffing; only the extension is trusted). CSV and
Cypher uploads are converted to JSON-LD shards server-side via new
shared helpers in fluree-db-api::import_source, which the CLI's local
import now also uses (one conversion implementation for both surfaces).

- Discovery advertises "source-upload" + import.source_formats;
  refused (and unadvertised) on Raft-replicated servers, where the
  pipeline's direct nameservice publishing would bypass the log.
- fluree create <ledger> --remote <name> --from data.ttl|dump.cypher|…
  uploads single files through the same mint→PUT→complete→poll flow and
  errors with a .flpack fallback hint when the server lacks the mode.
- Import job registry carries the source kind; complete branches
  restore vs pipeline in the same background-task slot.
- The import commit paths held tracing EnteredSpan guards across the
  blob-store awaits, making their futures !Send (invisible until a
  tokio::spawn needed them); store steps now use .instrument.
A bulk-imported root carries annotation_index: None; queries binding
relationship variables then take the scan-fallback path. The CLI's
local import already follows up with one reindex so the api attachment
provider's bulk-import bootstrap scan seals an authoritative arena —
the server's source-upload complete step now does the same when the
import reports has_annotations. Verified end-to-end: an uploaded
Cypher dump with relationship CREATEs finishes with a sealed arena and
the relationship queryable through the arena probe.

The probe example gains knobs used while verifying this
(PROBE_REINDEX2 / PROBE_LOAD_FIRST / PROBE_CACHE_CFG) and
property-bearing annotations.
An annotated insert followed by indexing left annotation_index=None
permanently. Three cooperating defects:

1. The api attachment provider returned None ('delta unknown') when the
   ledger wasn't resident in the LedgerManager — the norm for a
   write-only ingest, where the background indexer fires before any
   read loads the ledger. The build defensively dropped the arena and
   stamped had_annotation_arena, blocking the bootstrap scan forever.
   The provider now falls back to a *transient* backend load (never
   inserted into the cache — cache insertion from a background context
   disturbs the running handle's novelty bookkeeping) whose replay of
   un-indexed commits yields the complete event history.
2. A full rebuild under Augment coverage discarded a previously-sealed
   arena: root assembly had no way to reach the base arena. Fir6Inputs
   now carries the previous root's CID, and the Augment arm merges the
   prior arena's events with the delta (the incremental Phase 3d
   contract) before resealing. Prior-arena blobs follow the normal
   old-generation GC lifecycle.
3. Fluree::reindex read the NsRecord before quiescing the background
   indexer; a racing background publish left it rebuilding from a stale
   record (index_head_id gone), losing the just-sealed arena. The
   record is re-fetched after cancel + wait_for_idle.

Pinned by a four-cell seal matrix in it_edge_annotations_indexed,
including the background-race reproduction at probe scale. The
deliberate post-defensive-drop scan-fallback contract is unchanged and
its existing test still passes.
@bplatz
bplatz requested review from aaj3f, anh-h and zonotope July 18, 2026 16:55

@anh-h anh-h left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This addresses everything that was really problematic for me, and also saves me from testing a couple of KB-side workarounds I had half-drafted for the relationship-binding issue. Thanks for turning this around so fast.

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.

2 participants