Cypher Sequential Writes (Multi-Clause MERGE Composition)
Status: implemented (v1)
Owner: query/transact
Related: docs/query/cypher.md, docs/reference/cypher-support-matrix.md
Problem
A Cypher write statement is a pipeline: each clause observes the writes of the clauses before it, within one atomic statement. The canonical idiom:
UNWIND $rows AS row
MERGE (a:Person {id: row.src})
MERGE (b:Person {id: row.dst})
MERGE (a)-[:KNOWS]->(b)
The second MERGE's find-or-create check must see nodes the first MERGE just
created; the relationship MERGE must see both. A single Fluree Txn evaluates
its whole WHERE (including every NOT EXISTS MERGE guard) against one
snapshot horizon — the pre-transaction state — so it cannot express "later
clause observes earlier clause." That is the real architectural constraint
behind the v1 restriction ("MERGE must be the only write clause"), and no
rewriting into a single Txn can lift it.
Approach: sequential staging within one commit
Fluree already solves "N ordered operations, each observing the previous, one
atomic commit" for SPARQL 1.1 ;-separated updates (roadmap D-10,
Fluree::stage_transaction_from_txns): each operation stages against a
virtual state (base + prior operations' flakes applied as a commit-record-less
overlay via LedgerState::apply_staged_flakes_for_sequential_staging), flakes
fold last-wins per fact identity re-stamped to the single final t+1, and one
commit publishes.
The Cypher driver builds on the same machinery, factored into a reusable
SequentialStager (fluree-db-api/src/tx.rs). What Cypher adds over SPARQL
; is row threading: clauses pipe rows (variable bindings), not just
graph state. The driver is therefore an execute-time loop, not a static
decomposition:
- Row table init — the read prefix (
MATCH/UNWIND/WITH/ desugaredInlineRows) is evaluated as an ordinary read query against the base state, projecting every variable later clauses or theRETURNneed. Entity bindings materialize as IRIs, literals as typed values. Read-path evaluation gives the full read surface (includingWITHaggregation) for free. - Per-clause staging loop — each write clause becomes one or two sub-
Txns seeded with the current row table (aVALUESjoin), staged against the current virtual state via the full staging pipeline (policy, SHACL, uniqueness — per-clause validation, the same semantics choice D-10 made):- CREATE: one Txn; created-entity columns extend the row table as
skolem IRIs (
_:fdb-{clause_skolem}-{row}-{label}— solution order equalsVALUESrow order, pinned by test). - MERGE: (a) probe matched rows (
VALUES ⋈ patternquery on the virtual state); (b) absent rows dedup by their engine-evaluated identity-value tuple, first row per key creates (create-branch Txn withON CREATE SET); (c) flakes apply; (d) re-bind all rows (VALUES ⋈ patternon the updated state) — every row now matches, created or pre-existing, with Cypher's multi-match row multiplication; (e) rows that did not create runON MATCH SET(a seeded SET Txn). In-batch duplicate keys thus converge to sequential semantics: first occurrence creates +ON CREATE SET, later occurrences match +ON MATCH SET. Computed branch values that read graph state (for examplen.count = n.count + 1) evaluate after re-bind and stage row-by-row against the evolving virtual state; this lets each duplicate observe the preceding row's update. Simple literals and threaded row-column values remain batched. - SET / REMOVE: one seeded Txn; row table unchanged.
- DELETE in a multi-clause chain: rejected (clear error) in v1 — its relationship-existence / parallel-edge probes stay single-statement.
- CREATE: one Txn; created-entity columns extend the row table as
skolem IRIs (
- Finish —
SequentialStager::finish()produces oneStageResultover the original base: single commit att+1, union namespace delta, folded flakes. Zero staged flakes → the ordinary no-op update path. - RETURN — answered by one final read query (
VALUES(final rows) RETURN items) against the final virtual state: full expression surface (projections,DISTINCT,ORDER BY,SKIP/LIMIT) rather than the created-entity-only reconstruction single-Txn writes use.
Row threading representation
Rows re-enter lowering through ReadClause::InlineRows whose cells now admit
an internal-only Expr::SeededValue(UnresolvedValue, span) variant — never
produced by the parser, carrying entity IRIs (including stable _:fdb- blank
node labels) and typed literals losslessly into both the read-side lowering
(probe/re-bind/RETURN queries) and the write-side lowering (seeded Txns).
InlineRows vars now register as bound in the write lowering (bound_vars),
so seeded rows satisfy SET/REMOVE target checks.
Dispatch
cypher_write_plan classification order:
detect_conditional— the existing probe/branch shapes (standaloneMERGE … ON MATCH SET, per-row relationship MERGE pair-staging, DELETE guards) keep their proven paths.detect_sequential(new) — multi-write-clause statements containing a MERGE (MERGE chains, CREATE+MERGE mixes, interleaved SET/REMOVE), and the previously-rejected node MERGE under a plain leading MATCH (the driver's per-row partition makes it correct: one create per distinct absent key, every row binds).- Single-Txn lowering — everything else, including its existing clear errors.
WritePlan gains a Sequential variant. Call sites:
- Autocommit (
transact_cypher_statement) and Bolt explicit transactions (cypher_transaction_write) call the driver directly; the Bolt path stages against the transaction's private state, so statement pipelining and read-your-writes compose. - Consensus (
LocalCommitter): the plan resolves under the write lock per retry attempt (same head-check/refresh contract as conditional MERGE).TransactionReceiptcarries an optional Cypher RETURN table (serdedefaultNone); Raft mode rejects multi-clause RETURN in v1.
The UNWIND $batch desugar's last-wins row dedup
(dedup_rows_by_merge_identity) now applies only to single-write-clause
statements — it keys on the first node MERGE and would drop legitimate rows
in a chain; the driver owns duplicate-key semantics for multi-clause
statements.
Non-impact guarantees
- JSON-LD / Turtle / SPARQL transaction paths are untouched;
stage()in fluree-db-transact is not modified. stage_transaction_from_txnskeeps itslen == 1fast path; only its N ≥ 2 loop body moved intoSequentialStager.- Single-clause Cypher writes keep their existing plans byte-for-byte
(
detect_conditionalruns first;detect_sequentialfires only on shapes that previously errored).
Costs
Per MERGE clause: two or three VALUES-seeded engine queries (match probe,
identity-tuple evaluation for dedup, re-bind) plus one or two stagings. The
common path is row-bulk. A computed ON CREATE / ON MATCH value that must
read the current graph uses a correctness-first per-row query/stage loop so
duplicate rows observe prior updates. Row tables are capped
(MAX_SEQUENTIAL_ROWS) with a clear error.