Repository navigation
fix(store)!: fenced composition attachment, one sequenced write plan, complete owned units - #667
Merged
Merged
Conversation
…rite plan An attachment against a row that already exists is now one owner: `applyCompositionAttachmentUnderFence` re-reads the incumbent under the per-graph write lock, asks `decideCompositionIncumbent` what that incumbent means, and writes at most one retire and one attach. The only dimension callers differ on is the explicit disposition the write plan carries — `nodes.<Kind>.reparent(...)` states `onIncumbent: "replace"`, every get-or-create path states `"refuse"`. Before this, the get-or-create `partOf` postcondition decided from a LOCK-FREE read and then called the reparent machinery, whose fenced re-read always replaced. Two callers that each asked for a different whole could therefore both observe "no whole yet" and the second one silently MOVED the part instead of refusing. The lock-free read survives only as a cheap pre-check that lets an already-satisfied call stay read-only; every verdict and every write now comes from the fenced read. The get-or-create existing-row leg also runs ONE write plan: the attachment is resolved read-free, then applied inside the same transaction as the property update (`executeNodeUpsertUpdate`'s `compositionAttachment`). A refused update — a unique conflict, a validation error — leaves ownership and history untouched, where it previously committed the attachment in a separate transaction first. The resurrection leg joins the same path, so a resurrection that asks for the whole it already holds is idempotent rather than a lost-claim error.
A whole deleted through a `tx.measure(...)` scope counted as one write in that scope's sub-receipt but contributed nothing to its `cascadedParts`: the cascade refs travel through the node OPERATION CONTEXT, and a scope was built by wrapping the outer context's collections a second time — which catches write intents at the collection surface and nothing that happens inside an operation. A transaction's write surface (collections, identity facade, and the node operation context they are bound to) is now built by one parameterized builder, and a measure scope rebuilds it against the whole recorder CHAIN rather than re-wrapping the outer collections. `recordCascadedParts` fans out along that chain, so a measured delete populates the scope's receipt and every receipt enclosing it. Attribution stays structural — by the context the write ran through, never by timing — so a delete issued through the outer transaction while a scope is open still stays out of that scope, and concurrent or overlapping measures remain safe by construction.
`subgraph({ composition: true })` walked its part closure with a hop-bounded
recursive CTE capped at MAX_EXPLICIT_RECURSIVE_DEPTH, so a part chain longer
than 1000 levels came back TRUNCATED — a whole plus a prefix of its parts,
which is not an export unit: reloading it drops the tail of every deep subtree,
and a required part cut off from its whole cannot be recreated at all.
The closure is now `buildExhaustiveDirectedReachableCte`: `UNION` (not
`UNION ALL`) over a frontier of exactly `(id, kind)`, which is the device that
already makes the composition-union acyclicity probe exhaustive. Set semantics
on the frontier's whole column list reaches a fixpoint on any finite graph, so
there is no depth ceiling to exceed, no path column to carry, and no cycle
policy to choose — a cyclic relation written outside the write fence terminates
too, because a revisited node adds no new row. `maxDepth` and `cyclePolicy`
continue to bound the `edges` traversal a caller lists alongside the closure.
The hop-bounded directed builder this replaces had no other caller and is gone;
the `WITH RECURSIVE` emission inventory is updated in both directions. The
ontology and limitations pages now state completeness as delivered, with no
truncation caveat to read past.
`executeNodeUpsertUpdate` applied the get-or-create leg's property update and only then asked what the stated `partOf` meant, so a refused attachment left the update applied whenever the caller caught the refusal inside an enclosing `store.transaction(...)` — that leg runs ON the caller's transaction, with no nested frame to roll back. The base branch refused first (the postcondition ran in its own transaction ahead of the update), so this was a regression introduced by folding the two halves into one write plan. The fenced attachment is now two owners: `decideCompositionAttachmentUnderFence` re-reads the incumbent on the frame's target and returns the DECISION itself, and `applyCompositionAttachmentDecision` writes it. The update leg decides, updates, then applies, so every refusal the decision owns — a different incumbent whole, the same whole through a different realizing edge, a `props` value the live edge disagrees with — precedes the update's first statement. The refusals the attach write raises (a dead or missing whole, cardinality, acyclicity, a lost claim) still follow it and still abort the frame's own transaction: on the resurrection leg the part is a tombstone until the update restores it, so an endpoint read taken earlier would refuse every resurrection. The postcondition's lock-free pre-check now skips on the SAME conjunction the fenced satisfied arm reads, stated `props` included (`incumbentSatisfiesRequestedAttachment`, built on the props verdict the refusal also reads). It previously escalated to the per-graph write fence whenever `partOf.props` was stated at all, even when the live edge already held exactly those props — so an idempotent ingest that restates them took `BEGIN IMMEDIATE` / a session advisory lock on every call to be told it had nothing to write. Only the skip is ever taken lock-free; any disagreement still escalates to the fence, which owns the refusal. `executeNodeReparent` keeps the combined decide-then-apply call, and the write plan both attachment-only surfaces share is extracted so the get-or-create postcondition passes the request it already holds instead of handing back a raw attachment for `executeNodeReparent` to resolve a second time. `resolveCompositionAttachmentRequest` is now the one resolver every attachment surface reaches, with `onIncumbent` the only dimension they differ on.
…tions The in-process simulation forces its interleaving by running the competing write inside the loser's own read, on one connection, and says so: it cannot make two writers overlap. "Two get-or-create calls naming different wholes end with one attachment and one refusal" is a check-then-act decided by an application read, which is the class this suite exists for, so it gets the same two-pool, outcome-only treatment as the other four constraints here. `typegraph_edge_claims` joins the per-test TRUNCATE: the composition case and the existing cardinality-`one` case both reserve a claim row, and a row left behind would refuse the same write on the next run against this suite's persistent database.
…t unit
Two holes in the completeness claims the lane's own commits made.
`tx.measure(...)` rebuilt the write surface against the recorder chain but left
`TRANSACTION_RUNTIME`'s delete port pointing at the OUTER transaction's node
operation context, so a delete issued through that port inside a scope counted
as a write in the scope while its composition `cascadedParts` reached only the
enclosing receipts. `TransactionWriteSurface.nodeOperationContext` exists to
prevent exactly that and was never read. The scope now overlays a port built
from its own surface, non-enumerable like the outer one.
`subgraph({ composition: true })` promised "delivered or refused, never
partial" by citing the acyclicity probe, but unlike that probe it had no
refusal for an engine that cut the statement short: a `statement_timeout` or a
cancelled query surfaced as an opaque driver error. The composition closure's
fetch now classifies that case through the same `isStatementCutShortError`
predicate the probe uses, refusing with `ConfigurationError`
(`COMPOSITION_UNIT_INDETERMINATE`) naming the root. The caller's own `edges`
traversal is left alone: it is depth- and cycle-bounded and promises nothing a
cut-short statement would break.
…avels with
decideCompositionAttachmentUnderFence now reads the WHOLE endpoint's
liveness itself, under the caller's held write lock, when its verdict
is going to attach a new edge ("attach" or "replace"). Previously
that read only happened inside attachCompositionCreateEdge's ordinary
edge-create pipeline, which on the get-or-create ifExists: "update"
leg runs AFTER the property update — so a caller catching the refusal
inside an enclosing store.transaction(...) (no nested frame to roll
back) ended up with the property update committed and the part left
unattached, exactly the atomicity gap the decide/apply split was
supposed to close.
The PART endpoint stays owned by assertLiveEdgeEndpoints at write
time, since the resurrection leg's part row is still a tombstone
until the update restores it. The liveness predicate itself
(assertEndpointRowLive) moves to composition-create.ts, the module
edge-operations.ts already depends on for this exact class of
decision, so both call sites share one spelling instead of a second
copy drifting.
decideCompositionAttachmentUnderFence also gains a lock: GraphWriteLock
parameter, compile-time evidence (the same device
assertCompositionExistencePreserved uses) that its reads — the
incumbent re-read and now the whole's liveness read — cannot precede
the per-graph write lock.
Corrects the option docblock and changeset to state plainly which
three refusals (a lost composition claim, cardinality, acyclicity)
still follow the update and therefore still rely on this frame's own
transaction aborting, rather than claiming every refusal is now safe
under a nested store.transaction(...).
Adds a load-bearing test: an unattached optional part, a dead whole,
ifExists: "update", caught inside store.transaction(...) — the
property update stays unapplied and the part stays unattached.
Reverting the new liveness read (mutation check in the test) makes it
fail with the mutated property committed, confirming the test guards
the fix.
…sibility claim The #attachMeasure comment justified rebinding the internal delete port by claiming a scope that inherited the outer port would still report the delete's write via the collection wrappers. It would not: transactionDeleteNodeWithPolicy calls executeNodeDelete against a node operation context directly and never touches surface.nodes, so this delete never increments any receipt's writes counters, rebound or not. The only thing rebinding decides is where cascadedParts is attributed. Corrected the comment to state that plainly, including the resulting receipt shape: cascadedParts populated with writes left at zero for that same delete.
… it travels with The get-or-create update leg decided the incumbent and the whole's liveness before the property update, but the realizing edge's own reads (part liveness, cardinality, acyclicity, required existence) still ran inside the insert after it. A caller catching one of those refusals inside an enclosing transaction kept the update with the part unattached. The attach is now split into a read half and the insert; the leg prepares before the update and issues only the insert after it. Load-bearing test: a whole-side cardinality refusal caught inside a transaction leaves the property unapplied (fails with the prepare moved below the update).
pdlug
added a commit
that referenced
this pull request
Sep 23, 2026
… complete owned units (#667) Second whole-branch review of #637, the store and query findings: `getOrCreate({ partOf })` could silently reparent during a race, an attachment could commit even though `getOrCreate` rejected, scoped `tx.measure` receipts omitted cascaded parts, and a complete owned-unit export still truncated silently at 1,000 hops. `applyCompositionAttachmentUnderFence` re-reads the incumbent under the per-graph write lock, asks `decideCompositionIncumbent` what that incumbent means, and writes at most one retire and one attach. The only dimension callers differ on is the explicit disposition the write plan carries: `nodes.<Kind>.reparent(...)` states `onIncumbent: "replace"`, every get-or-create path states `"refuse"`. Before this, the get-or-create `partOf` postcondition decided from a lock-free read and then called the reparent machinery, whose fenced re-read always replaced, so two callers that each asked for a different whole could both observe "no whole yet" and the second one silently moved the part instead of refusing. The lock-free read survives only as a pre-check that lets an already-satisfied call stay read-only, stated `props` included (`incumbentSatisfiesRequestedAttachment`, built on the same props verdict the refusal reads); every verdict and every write comes from the fenced read. `resolveCompositionAttachmentRequest` is the one resolver every attachment surface reaches. A new two-connection test in the concurrency suite proves the race outcome (one attachment, one refusal) on real overlapping writers rather than an in-process interleaving. writes The existing-row `ifExists: "update"` and resurrection legs run the attachment inside the same transaction as the property update (`executeNodeUpsertUpdate`'s `compositionAttachment`). Because that leg runs on a caller's enclosing `store.transaction(...)` when there is one, with no nested frame to roll back, the frame is ordered so every read the attachment owes precedes the update's first statement: the fenced incumbent decision and the whole's liveness (`decideCompositionAttachmentUnderFence`, which now carries `lock: GraphWriteLock` as compile-time evidence like `assertCompositionExistencePreserved`), then the realizing edge's own preparation (`prepareCompositionAttachmentDecision`: the part's liveness on the update leg, cardinality, acyclicity, the required-existence rule). `attachCompositionCreateEdge` is split into that read half and `insertPreparedCompositionEdge`, and the leg issues only the insert after the update. A refusal from any read leaves nothing written, and a refused property update (a unique conflict, a validation error) never reaches the insert, so ownership and history stay untouched in every case. The resurrection leg skips only the part endpoint read, since its row is a tombstone until the update restores it; the whole is still refused at decide time. `assertEndpointRowLive` is the one row-liveness predicate both `assertLiveEdgeEndpoints` and the decide half call. Load-bearing tests cover a conflicting incumbent, a dead whole, and a whole-side cardinality refusal, each caught inside an enclosing transaction with the property left unapplied; each fails with the corresponding read moved back below the update. A whole deleted through a `tx.measure(...)` scope counted as one write in that scope's sub-receipt but contributed nothing to its `cascadedParts`: cascade refs travel through the node operation context, and a scope was built by wrapping the outer context's collections a second time. A transaction's write surface (collections, identity facade, and the node operation context they bind to) is now built by one parameterized builder, and a measure scope rebuilds it against the whole recorder chain; `recordCascadedParts` fans out along that chain, so a measured delete populates the scope's receipt and every receipt enclosing it. The scope also overlays the runtime's internal delete port with one built from its own surface, so a delete issued through that port inside a scope is attributed to the scope. Attribution stays structural, by the context the write ran through, never by timing. `subgraph({ composition: true })` walked its part closure with a hop-bounded recursive CTE capped at 1,000 levels, so a deeper part chain came back truncated, which is not an export unit. The closure is now `buildExhaustiveDirectedReachableCte`: `UNION` over a frontier of exactly `(id, kind)`, the same device that makes the composition-union acyclicity probe exhaustive, so it reaches a fixpoint on any finite graph with no depth ceiling, no path column, and no cycle policy. `maxDepth` and `cyclePolicy` continue to bound the `edges` traversal a caller lists alongside the closure. An engine that cuts the closure statement short (a `statement_timeout`, a cancelled query) is refused with `ConfigurationError` (`COMPOSITION_UNIT_INDETERMINATE`) through the same `isStatementCutShortError` predicate the probe uses, instead of surfacing an opaque driver error. The hop-bounded directed builder had no other caller and is removed. The ontology and limitations pages state completeness as delivered. Both surfaces are new in this release, so the incumbent disposition and the exhaustive closure are stated in their changesets as delivered behavior rather than as breaking changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second whole-branch review of #637, the store and query findings:
getOrCreate({ partOf })could silently reparent during a race, an attachment could commit even thoughgetOrCreaterejected, scopedtx.measurereceipts omitted cascaded parts, and a complete owned-unit export still truncated silently at 1,000 hops.One fenced owner for an attachment against an existing row
applyCompositionAttachmentUnderFencere-reads the incumbent under the per-graph write lock, asksdecideCompositionIncumbentwhat that incumbent means, and writes at most one retire and one attach. The only dimension callers differ on is the explicit disposition the write plan carries:nodes.<Kind>.reparent(...)statesonIncumbent: "replace", every get-or-create path states"refuse". Before this, the get-or-createpartOfpostcondition decided from a lock-free read and then called the reparent machinery, whose fenced re-read always replaced, so two callers that each asked for a different whole could both observe "no whole yet" and the second one silently moved the part instead of refusing. The lock-free read survives only as a pre-check that lets an already-satisfied call stay read-only, statedpropsincluded (incumbentSatisfiesRequestedAttachment, built on the same props verdict the refusal reads); every verdict and every write comes from the fenced read.resolveCompositionAttachmentRequestis the one resolver every attachment surface reaches. A new two-connection test in the concurrency suite proves the race outcome (one attachment, one refusal) on real overlapping writers rather than an in-process interleaving.The get-or-create update leg is one write plan, sequenced reads then writes
The existing-row
ifExists: "update"and resurrection legs run the attachment inside the same transaction as the property update (executeNodeUpsertUpdate'scompositionAttachment). Because that leg runs on a caller's enclosingstore.transaction(...)when there is one, with no nested frame to roll back, the frame is ordered so every read the attachment owes precedes the update's first statement: the fenced incumbent decision and the whole's liveness (decideCompositionAttachmentUnderFence, which now carrieslock: GraphWriteLockas compile-time evidence likeassertCompositionExistencePreserved), then the realizing edge's own preparation (prepareCompositionAttachmentDecision: the part's liveness on the update leg, cardinality, acyclicity, the required-existence rule).attachCompositionCreateEdgeis split into that read half andinsertPreparedCompositionEdge, and the leg issues only the insert after the update. A refusal from any read leaves nothing written, and a refused property update (a unique conflict, a validation error) never reaches the insert, so ownership and history stay untouched in every case. The resurrection leg skips only the part endpoint read, since its row is a tombstone until the update restores it; the whole is still refused at decide time.assertEndpointRowLiveis the one row-liveness predicate bothassertLiveEdgeEndpointsand the decide half call. Load-bearing tests cover a conflicting incumbent, a dead whole, and a whole-side cardinality refusal, each caught inside an enclosing transaction with the property left unapplied; each fails with the corresponding read moved back below the update.Cascaded parts reach every measured receipt
A whole deleted through a
tx.measure(...)scope counted as one write in that scope's sub-receipt but contributed nothing to itscascadedParts: cascade refs travel through the node operation context, and a scope was built by wrapping the outer context's collections a second time. A transaction's write surface (collections, identity facade, and the node operation context they bind to) is now built by one parameterized builder, and a measure scope rebuilds it against the whole recorder chain;recordCascadedPartsfans out along that chain, so a measured delete populates the scope's receipt and every receipt enclosing it. The scope also overlays the runtime's internal delete port with one built from its own surface, so a delete issued through that port inside a scope is attributed to the scope. Attribution stays structural, by the context the write ran through, never by timing.The complete owned unit, with no hop ceiling
subgraph({ composition: true })walked its part closure with a hop-bounded recursive CTE capped at 1,000 levels, so a deeper part chain came back truncated, which is not an export unit. The closure is nowbuildExhaustiveDirectedReachableCte:UNIONover a frontier of exactly(id, kind), the same device that makes the composition-union acyclicity probe exhaustive, so it reaches a fixpoint on any finite graph with no depth ceiling, no path column, and no cycle policy.maxDepthandcyclePolicycontinue to bound theedgestraversal a caller lists alongside the closure. An engine that cuts the closure statement short (astatement_timeout, a cancelled query) is refused withConfigurationError(COMPOSITION_UNIT_INDETERMINATE) through the sameisStatementCutShortErrorpredicate the probe uses, instead of surfacing an opaque driver error. The hop-bounded directed builder had no other caller and is removed. The ontology and limitations pages state completeness as delivered.Both surfaces are new in this release, so the incumbent disposition and the exhaustive closure are stated in their changesets as delivered behavior rather than as breaking changes.