Repository navigation
Dispatch ADR-060 invariant rules from every bulk write path - #3210
Conversation
Pragmatic Code Review: PR #3210, dispatch ADR-060 invariant rules from every bulk write pathReview Type: Initial (high effort, adversarial; data-integrity write path). Reviewed at Requirements Check (vs #2960)
All six functions named in the issue are covered, and none of the bulk create/update paths skips dispatch any more. The core goal is met, and the refactor is a net simplification: two store methods gone, How I assessed the author's deliberate decisions
Code Review Findings🔴 Critical1. A batch guard dropped while another task's transaction has parked its buffer leaves an orphaned
Before this PR the same interleaving also lost events. But it self-healed to Principle: shared mutable state needs an ownership protocol that holds under concurrency, not just under nesting on one call stack. "Restore what I saved" is only safe if nobody else could have changed ownership in between. Suggested fix: keep the parked batch inside the slot so the guard's owner can still reach it. For example,
Add a crate-internal regression test: open a guard, start a 🟡 Important2. The mirror race ( The new 3. The doc comment claims a guarantee the condition evaluator does not provide.
Conditions are evaluated by Principle: comments that assert guarantees must be true. A wrong one is worse than none on a correctness-critical path. 4. The rewritten collection-collision journaling in The old code got within-batch detection for free, because each 5. Test gaps on the paths this PR changes.
Principle: tests should cover every branch the change introduces, especially rollback and concurrency branches on a write path. 🟢 Suggestions6. The write guard is held for per-row CEL evaluation on large imports. 📁 7. The event burst is no longer paced. 📁 8. Stale snapshots across rows. 📁 9. Nit: a 10. Nit: 11. Nit: downstream comments are now stale. Standards: no issue numbers in code comments, no lint suppressions, and the Downstream sync contract: Recommendation: REQUEST CHANGESThe dispatch wiring itself is correct and complete, and the change is a real integrity improvement. The blocker is the |
mstomar125
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review: REQUEST CHANGES (posted as a comment — GitHub blocks self-approval). Full findings in the review comment above.
Address-review summary (round 1) — commit ba9ee71✅ Addressed
⏭️ Skipped (with reasons)
🧪 Tests (targeted, one run at a time per machine-load guidance): Re-Review DecisionDecision: RE-REVIEW NEEDED |
Pragmatic Code Review, Re-Review Round 1: PR #3210, commit
|
| Op | Immediate |
Batching(m) |
Transactional{buf, parked: None} |
Transactional{buf, parked: Some(m)} |
|---|---|---|---|---|
begin_batch_emit |
→ Batching(∅) |
debug_assert (2 guards) | → parked = Some(∅) |
debug_assert (2 guards) |
| guard drop | no-op | flush m → Immediate |
no-op | parked.take(), flush m; buf untouched |
| tx start (write guard held) | → T{∅, None} |
→ T{∅, Some(m)} |
unreachable (write guard serializes) | unreachable |
tx end / unwind (end_transaction) |
unreachable | unreachable | → Immediate, return buf |
→ Batching(m), return buf |
emit_event / notifier |
send | keyed → m; unkeyed → send | → buf | → buf |
| post-commit merge (lock released) | send | keyed → m | send | keyed → parked m |
The fix relies on one invariant: a batch map exists in the slot (as Batching or parked) exactly when one guard is live. begin creates the map together with its guard. Guard drop removes whichever map is present, exactly once (Option::take / replace). Tx start and end move the map without dropping it. Nothing else touches it. The tx buffer is now only ever taken by end_transaction, and the ResetOnDrop flag stops it being taken twice. With that invariant, here are the two-task interleavings I walked:
- The original 🔴 sequence (G open → tx parks it → G drops → tx ends): G's drop flushes m, and the tx keeps
buf.end_transactionseesparked: Noneand returns toImmediate. The post-commit merge then broadcasts. No orphan, and nothing is lost. ✅ - G drops, then G2 opens, both mid-tx (the reconnect loop's next page): G2's batch is parked. The tx ends by restoring
Batching(m2), which G2 owns, and the tx's events join m2. They are delayed, not lost. ✅ - Tx ends and releases the lock, then G drops before the post-commit merge: the merge sees
Immediateand broadcasts. ✅ If G2 opens in between, the events join m2. ✅ If another tx T2 starts in between, the events go into T2'sparked. ✅ - Guard opened during a tx (old 🟡 Create design system foundation #2): the batch is parked instead of overwriting
buf. The tx's committed events join it. ✅ - Rollback:
bufis discarded, the parked batch is restored untouched, and there is no flush. ✅ - Panic in
f/ cancelled future:ResetOnDrop→end_transactionruns while the write guard is still held, restoringparkedand discardingbuf.end_transaction's owndebug_assert!(false)can't fire during unwind, because only tx end ever replacesTransactional. ✅ - Double flush: not possible.
parkedgoes throughtake()andbufis returned once. ✅ - Guard drop in
Immediate: a clean no-op. It's only reachable after a second guard's drop already took the batch, which is the pre-existing multi-guard case. ✅
Can a debug_assert fire in legitimate use? Only the pre-existing "two live guards" assertion in begin_batch_emit, for example a user bulk_delete (which opens its own guard) running while the sync reconnect holds a page guard. That fired identically before this PR, and the PR neither introduces nor worsens it (see 🟢 #3). The Transactional-on-start and end_transaction asserts can't be reached.
Regression tests: batch_guard_dropped_during_a_transaction_leaves_the_slot_immediate fails against 6246781d: the old drop replaced Transactional with Immediate and flushed nothing, so "batched" is never received. That confirms the author's mutation claim. batch_opened_during_a_transaction_receives_its_committed_events also fails against 6246781d, via begin_batch_emit's old debug_assert!(Immediate). Both run on #[tokio::test]'s current-thread runtime; Notify stores a permit, so there's no lost-wakeup hang. They're sound.
Other items
- Desktop shell with resizable panels #3 (doc on conditions vs actions): corrected accurately at
bulk.rs:255-259. ✅ - Enhanced BaseNode with Hierarchical Circle Indicators + TextNode Implementation #4 (collision matching): the within-batch key is now lowercased content against an earlier active row's lowercased title. That exactly mirrors
get_collection_by_name(LOWER(title) = content.to_lowercase(), active only), so a batch detects what per-row creates would have. The new test covers a stored collision (both sides), an in-batch collision (both sides) and an archived row freeing its name. Each of its assertions would fail if the corresponding branch were broken, because the archived case only passes iflifecycle_statusgates the claim. ✅ - Basic storage integration #5 (tests):
- The schema-description test can't pass spuriously: it asserts
Errand the schema's absence, and it fails onmain, where there is no dispatch. sync_tagged_bulk_update_...would fail if the no-op were removed, because the reject rule would fire.rejected_bulk_create_inside_a_batch_guard_...is a guard against regression in the rollback-under-batch branch. It would also pass on6246781d, which is fine because that branch was already correct; it just isn't a fail-without-fix test.- ✅ overall
- The schema-description test can't pass spuriously: it asserts
Findings
🟡 Important
1. The with_transaction doc still describes the old model, and the summary's claim that it was reworded is inaccurate.
📁 packages/core/src/services/node_service/mod.rs:697-702 (and :686-690)
The summary says the doc was "reworded around concurrency", but the commit leaves this paragraph unchanged:
"A transaction may run inside a caller's
begin_batch_emitguard (the reverse — a batch opened inside a transaction — is still unsupported). The batch's buffer is set aside while the transaction buffers its own events, then restored…"
That now contradicts the code. A batch opened during a transaction is supported and is exactly what batch_opened_during_a_transaction_receives_its_committed_events tests. The parked batch is also not necessarily restored. Similarly, :689 says the next caller sees batch_state "provably already been reset to Immediate", when it may now be Batching. BatchState's own doc at :484-489 is correct. Please make the with_transaction doc match it: parked in the slot, flushed by the guard if it drops first, restored only if still parked, and a batch opened mid-transaction gets parked. It's a comment-only fix, but this doc is the concurrency contract every write path relies on.
Principle: a contract doc that contradicts the implementation is worse than none, especially on shared concurrency state.
🟢 Suggestions (pre-existing, not introduced by this commit; follow-up candidates)
2. Another task's events can still land in the running transaction's buffer. 📁 mod.rs notifier Transactional { buf, .. } arm (~:1456), emit_event (~:3019); db/sqlite_store/nodes.rs:334-351. SqliteStore::create_node (and other non-tx store writes) release the write guard at the end of the execute statement, then call notify. Many NodeService methods likewise emit_event after a non-tx store write. If another task's with_transaction has taken the slot in between, that event is pushed into its buf and is discarded if that transaction rolls back. It also skips a parked batch. The misattribution is the same kind with_transaction's doc says was closed, but it comes from emitters outside the write guard. It existed on main (the Transactional arm is unchanged in substance), so it doesn't block this PR. The fix direction would be for emitters that don't hold the write guard to bypass buf: route to parked if present, otherwise send.
3. Two concurrent guards are still unsafe. A bulk_delete guard opened during a sync reconnect page guard hits begin_batch_emit's debug_assert in debug builds. In release it overwrites the first batch, and the first guard's drop then flushes the second's. Guards have no identity. This is pre-existing and unchanged. A guard token (generation id) would make drops precise. Follow-up.
4. Cross-task merge order under last-write-wins. If two transactions' post-commit merges run in the opposite order to their commits, which is possible because the merge runs after the lock is released, the batch keeps the older NodeUpdated snapshot for a node. Consumers that re-read by id are unaffected. Before this PR the same reordering existed in immediate broadcast order. Noting it only.
Skipped items
- Basic storage integration #5 async markdown branch: the reasoning holds. It's the same
bulk_create_hierarchycall, and the failure event has its own test. - Tauri desktop app launches successfully #6: out of scope, and the reasoning holds.
- Svelte frontend renders with placeholder panels #7: only partly holds. The trusted path already burst-flushed, but
bulk_create_hierarchy(markdown/daemon per-file import) did lose its per-row pacing in this PR. Impact is low because single documents rarely exceed 4096 nodes. Accept. - Development server works with hot reload #8: acceptable as a documented edge. The argument that "the single-node path doesn't face it" is really why it's a divergence, but invariant actions targeting other rows of the same batch are rare. Accept.
- Build process creates distributable package #9: the clone does allocate
play_id: Stringper rule per row, but it's negligible next to the SQL. Accept. - No compilation errors or warnings #10, Set up Tauri + Svelte project structure #11: accept.
Newly broken?
I found nothing. BatchEmitGuard::drop in Immediate is a no-op. The notifier's Transactional { buf, .. } arm keeps its previous behavior (see 🟢 #2 for the pre-existing gap). ResetOnDrop's bool flag is set immediately after the only non-unwind end_transaction, with nothing that can panic in between. The bulk.rs collision change only narrows who can claim a name (title required), which matches the stored-side lookup.
Recommendation: APPROVE
The 🔴 is genuinely fixed. I found no lost, double-flushed or orphaned batch in any interleaving of begin, drop, tx start/end, unwind, emit or merge across two tasks. The regression tests fail without the fix. Please fix the stale with_transaction doc (🟡 #1, comment-only) before merging; it doesn't need another review round. File 🟢 #2 and #3 as follow-ups. Both predate this PR, and #2 is a real event-loss path for writes that run concurrently with a rolled-back transaction.
Address-review summary (round 2) — commit ac56096Re-review verdict on ba9ee71: APPROVE (🔴 #1 park/restore race confirmed fixed across all two-task interleavings incl. unwind/rollback; both new regression tests fail on 6246781). ✅ Addressed
⏭️ Not addressed here (pre-existing, flagged as follow-ups by the reviewer)
Re-Review DecisionDecision: NO RE-REVIEW NEEDED |
Bulk create/update paths never called ADR-060 synchronous invariant dispatch, and the reactive engine skips invariant rules, so a bulk-written node's invariants were never evaluated. - bulk_create, bulk_create_hierarchy (+ _in_tx), bulk_create_hierarchy_trusted and bulk_update now insert/update inside NodeService::with_transaction and run invariant dispatch per row in that same transaction; a reject fails the whole batch with PlayRuleRejected and rolls back every row. - Creation rules are looked up once per distinct node type (invariant_rules_for_creation / run_creation_invariant_rules_in_tx split out of dispatch_invariant_rules_in_tx); update rules stay keyed on each node's changed properties via dispatch_invariant_rules_for_update_in_tx. - The trusted path still enforces invariants: it skips schema-shape validation only, and serves user-facing directory import. - with_transaction composes inside a begin_batch_emit guard: committed events join the caller's batch (sync replay pages through bulk_create). - bulk_create is now genuinely atomic and journals collection-name collisions post-commit, including within-batch ones. - Store: bulk_update_in_tx extracted; batch_create_nodes and bulk_create_hierarchy_root_notify deleted (no callers); NodeService::bulk_create_hierarchy_root_notify folded into bulk_create_hierarchy (callers in markdown import and daemon updated). - New tests/bulk_invariant_dispatch_test.rs (10 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- BatchState::Transactional now carries the begin_batch_emit batch it parked, instead of the transaction holding it on its own stack. A guard dropped while another task's transaction runs takes and flushes its parked batch; the transaction then ends with the slot Immediate rather than restoring an ownerless batch that swallowed every later event. A batch opened while a transaction runs is parked under it instead of overwriting its buffer, and receives its committed events. - bulk_create matches within-batch collection-name collisions the way get_collection_by_name matches stored ones (lowercased content against an earlier active row's lowercased title). - Correct insert_bulk_hierarchy_rows_in_tx's doc: rule conditions read through pooled connections and cannot see the batch's uncommitted rows. - Tests: two BatchState concurrency regressions (unit), and integration tests for collision journaling, schema-description dispatch through bulk_create_hierarchy_in_tx, rejected bulk_create under a batch guard, and the sync no-op for bulk_update. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…'s doc Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ac56096 to
c68484b
Compare
Closes #2960
Problem
ADR-060 synchronous invariant dispatch ran on every single-node create/update path but on none of the bulk paths, and the reactive engine deliberately skips
RuleClass::Invariant. So a node written throughbulk_create,bulk_create_hierarchy(+_in_tx,_root_notify),bulk_create_hierarchy_trustedorbulk_update— markdown import, the daemon's directory/bulk import, the sync/skill pipelines — never had its invariants evaluated.Change
NodeService::with_transaction(store_in_txinserts/updates, buffered events), and dispatches invariants per row inside that transaction after the batch lands. Any row'srejectreturns the sameNodeServiceError::PlayRuleRejectedsingle-node writes return and rolls back the whole batch (plus any invariant action's writes and the buffered events).dispatch_invariant_rules_in_txsplit intoinvariant_rules_for_creation(lookup, filtered to invariant rules, same sync/lifecycle no-ops) +run_creation_invariant_rules_in_tx(execution). Bulk looks rules up once per distinct node type; rows of a type with no invariant rule cost nothing (noNodebuilt). Subtype matching islookup_rules' ADR-078 ancestry fan-out, unchanged.bulk_updatecallsdispatch_invariant_rules_for_update_in_txper node with its realchanged_properties(keys are per-node, so no per-type cache), on a node reflecting what the store persisted (version/title/lifecycle applied). Storebulk_update_in_txextracted;bulk_updatedelegates to it.with_transactioninsidebegin_batch_emit: the sync replay batches pages throughbulk_create/bulk_updateunder a batch guard.with_transactionnow parks and restores the outer batch and, on commit, deposits its events into it (node-keyed last-write-wins, relationship events flushed), instead of clobbering it.bulk_createwas documented atomic but wasn't (per-row autocommit); it now is. Collection-name collision journaling is preserved post-commit, including collisions within the batch.batch_create_nodes, storebulk_create_hierarchy_root_notify, andNodeService::bulk_create_hierarchy_root_notify(callers in markdown import and daemon switched tobulk_create_hierarchy— identical behaviour). Bulk hierarchy events now carry the service'sclient_idlike every otherNodeServicewrite.Tests
New
packages/core/tests/bulk_invariant_dispatch_test.rs(10 tests, each mutation-checked by disabling the dispatch / sync no-op / batch composition): whole-batch reject forbulk_create,bulk_create_hierarchy, trusted path,bulk_update(property_changed), markdown import; satisfying batch commits and runs non-reject actions; base-type rule on a subtype row; update rule only matches changed properties; sync-taggedbulk_createskips dispatch; bulk writes inside a batch guard deliver events on drop.bun run test:changed(scripts + full Rust workspace nextest): pass (pre-rebase; rebase onto main was patch-identical)bulk_invariant_dispatch_test10/10,playbook_invariant_integration_test30/30,playbook_engine_integration_test12/12,merge_nodes_test23/23;cargo test -p nodespace-core --lib1898/1898 (pre-rebase)bun run quality:fixclean🤖 Generated with Claude Code