khive_runtime/atomic_runner.rs
1//! ADR-099 migration step 3 (sub-slice B2) — the atomic runner: the
2//! synchronous commit-pass mechanism that applies a caller-supplied sequence
3//! of prepared write plans ([`crate::atomic_plan`]) as **ONE**
4//! [`SqlAccess::atomic_unit`], under a per-op `SAVEPOINT`, committing every
5//! plan or rolling back the whole unit.
6//!
7//! This module is the **mechanism only**. It has no production caller in
8//! B2 — no verb dispatch, no CLI `--atomic` surface, no daemon wiring.
9//! Tests in this file are the only consumer; wiring a real per-verb
10//! `prepare` step (ops → [`AtomicOpPlan`]) and the `exec --ops-file
11//! --atomic` CLI surface is ADR-099 migration steps 1 (cont'd) and 4 — the
12//! **B3 wiring point** referenced throughout this file.
13//!
14//! # The atomic-unit suspend-free invariant, restated at this seam
15//!
16//! [`run_atomic_unit`] is the one place in this crate that builds an
17//! [`AtomicUnitOp`] closure and hands it to [`SqlAccess::atomic_unit`]. That
18//! trait method carries a hard contract (its own doc comment,
19//! `crates/khive-storage/src/sql.rs`, and `crates/khive-db/src/sql_bridge.rs`
20//! `block_on_sync`): **the closure's future must resolve on its first
21//! poll** — synchronous DML against the provided `&mut dyn SqlWriter` only,
22//! never a real `.await` on embedding, ANN warming, or any other suspending
23//! work. This module honors that invariant structurally, not by convention:
24//! every statement the commit-pass closure below drives comes from
25//! `AtomicOpPlan::plan_statements` (private — the runner's own internal
26//! flattening step), which can only ever produce
27//! [`PlanStatement`]s — plain parameterized SQL, the same shape ADR-099 D1's
28//! prepare pass produces for the v1 DML-only admissible verb set (ADR-099
29//! D3). There is no code path in this module that can hand `atomic_unit` an
30//! embedding call or any other suspending future — see the paired
31//! suspend-trap tests at the bottom of this file for the two things this
32//! promise is checked against: the real commit pass resolving on first poll
33//! (the happy-path proof), and a hand-built closure that deliberately
34//! suspends failing loudly through the exact same seam (the misuse-is-caught
35//! proof).
36//!
37//! # Two-phase shape (ADR-099 D1)
38//!
39//! Only the **commit pass** (phase 2) lives here: given an already-prepared
40//! `Vec<AtomicOpPlan>` (phase 1, the async prepare pass, is out of scope for
41//! B2 — a test-only caller constructs plans directly), [`run_atomic_unit`]
42//! opens one `atomic_unit`, applies each plan's statements under a named
43//! `SAVEPOINT`, and returns either every op's collected
44//! [`PostCommitEffect`]s (phase 3, the async post-commit pass — this is the
45//! **B3 wiring point**: nothing in B2 executes these effects, a test
46//! consumer only drains the returned list) or the first op's failure and its
47//! index.
48
49use std::any::Any;
50use std::sync::{Arc, Mutex};
51
52use khive_storage::{AtomicUnitOp, SqlAccess, SqlStatement, SqlWriter, StorageError};
53
54use crate::atomic_plan::{
55 AffectedRowGuard, DeletePlan, GovernancePlan, GtdCompletePlan, GtdTransitionPlan, LinkPlan,
56 MergePlan, PlanStatement, PostCommitEffect, UpdatePlan,
57};
58
59/// One admissible op's prepared write plan (ADR-099 D3's v1 admissible verb
60/// groups), ready for the commit pass. This is the `Vec<AtomicOpPlan>` shape
61/// [`run_atomic_unit`] consumes — the runner is agnostic to which verb
62/// produced a given plan; it only needs each plan's ordered statements
63/// (`plan_statements`) and any deferred post-commit effect
64/// (`post_commit_effect`).
65#[derive(Debug, Clone)]
66pub enum AtomicOpPlan {
67 Update(UpdatePlan),
68 Delete(DeletePlan),
69 Link(LinkPlan),
70 Merge(MergePlan),
71 GtdTransition(GtdTransitionPlan),
72 GtdComplete(GtdCompletePlan),
73 Governance(GovernancePlan),
74}
75
76impl AtomicOpPlan {
77 /// This op's statements, in the order the commit pass must apply them.
78 ///
79 /// For [`AtomicOpPlan::Merge`], the predicate-based rewires
80 /// (`MergePlan::rewires`) come first, converted to unguarded
81 /// [`PlanStatement`]s (ADR-099 D1 rule 1 — a rewire may legitimately
82 /// touch zero or many rows and is never guarded), followed by the
83 /// always-guarded lifecycle writes (`MergePlan::lifecycle`). Every other
84 /// variant already carries a flat `Vec<PlanStatement>` in apply order.
85 fn plan_statements(&self) -> Vec<PlanStatement> {
86 match self {
87 AtomicOpPlan::Update(p) => p.statements.clone(),
88 AtomicOpPlan::Delete(p) => p.statements.clone(),
89 AtomicOpPlan::Link(p) => vec![p.statement.clone()],
90 AtomicOpPlan::Merge(p) => {
91 let mut statements: Vec<PlanStatement> = p
92 .rewires
93 .iter()
94 .map(|rewire| PlanStatement {
95 statement: rewire.statement.clone(),
96 guard: None,
97 })
98 .collect();
99 statements.extend(p.lifecycle.clone());
100 statements
101 }
102 AtomicOpPlan::GtdTransition(p) => p.statements.clone(),
103 AtomicOpPlan::GtdComplete(p) => p.statements.clone(),
104 AtomicOpPlan::Governance(p) => p.statements.clone(),
105 }
106 }
107
108 /// The deferred post-commit effect this op's plan recorded, if any.
109 ///
110 /// [`UpdatePlan`] carries a [`PostCommitEffect`] field for the `update`
111 /// reindex caveat (ADR-099 D3). Since the B3 fix round (GAP-5),
112 /// [`GtdTransitionPlan`]/[`GtdCompletePlan`] also carry one, for the
113 /// best-effort lifecycle audit row. Since the #750 fix-round 2 (codex
114 /// r2 High 2), [`DeletePlan`] also carries one, for the note-mutation
115 /// hook fire on a committed note delete. Every other admissible verb's
116 /// apply is pure DML with no deferred side effect. `merge`'s existing
117 /// post-transaction vector re-insert (D3: "merge already performs its
118 /// vector re-insert after its transaction") is the handler's own
119 /// pre-existing behavior outside this plan shape — [`MergePlan`] itself
120 /// carries no `post_commit` field, so this runner records none for it.
121 fn post_commit_effect(&self) -> Option<PostCommitEffect> {
122 match self {
123 AtomicOpPlan::Update(p) if p.post_commit != PostCommitEffect::None => {
124 Some(p.post_commit.clone())
125 }
126 AtomicOpPlan::Delete(p) if p.post_commit != PostCommitEffect::None => {
127 Some(p.post_commit.clone())
128 }
129 AtomicOpPlan::GtdTransition(p) if p.post_commit != PostCommitEffect::None => {
130 Some(p.post_commit.clone())
131 }
132 AtomicOpPlan::GtdComplete(p) if p.post_commit != PostCommitEffect::None => {
133 Some(p.post_commit.clone())
134 }
135 _ => None,
136 }
137 }
138}
139
140/// Why a single op's plan failed inside the commit pass (ADR-099 acceptance
141/// criteria: "the failing op index is recorded").
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub enum AtomicOpFailure {
144 /// The statement executed without a SQL error, but its affected-row
145 /// count did not satisfy the guard prepare attached to it (ADR-099 D1
146 /// rule 2 — "a prepare-time validation is a plan hypothesis, re-verified
147 /// under the transaction, never a commitment"). This is the shape both
148 /// the dangling-edge and zero-row acceptance criteria trip.
149 GuardFailed {
150 statement_label: Option<String>,
151 expected: AffectedRowGuard,
152 observed: u64,
153 },
154 /// The statement itself returned a storage error (a genuine SQL
155 /// failure — malformed SQL, a constraint violation not modeled as a
156 /// guard, etc.), not a guard mismatch.
157 SqlError {
158 statement_label: Option<String>,
159 message: String,
160 },
161}
162
163/// The whole-unit outcome of a completed [`run_atomic_unit`] call — the
164/// commit pass ran to a clean, distinguishable verdict (never returned for
165/// a seam-level failure; see [`AtomicRunnerError`] for that case).
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum AtomicRunOutcome {
168 /// Every op's plan applied and the unit committed. Carries every op's
169 /// deferred post-commit effect, in op order. Draining this list
170 /// (running the actual reindex/embedding side effects) is phase 3 of
171 /// ADR-099 D1 and is out of scope for B2 — **the B3 wiring point**: a
172 /// production caller runs these after `run_atomic_unit` returns, outside
173 /// any transaction; a test caller in this file only asserts on the
174 /// list's contents.
175 Committed { post_commit: Vec<PostCommitEffect> },
176 /// The op at `failed_op_index` failed; the whole unit rolled back
177 /// (ADR-099 D1: "the whole unit rolls back and the failing op index is
178 /// recorded"). No op's writes are present in the database after this
179 /// outcome — including any earlier op whose own `SAVEPOINT` had already
180 /// been `RELEASE`d, because `RELEASE` only merges a savepoint's changes
181 /// into its parent transaction; it does not commit them independently
182 /// of the outer `atomic_unit` transaction's own COMMIT/ROLLBACK.
183 RolledBack {
184 failed_op_index: usize,
185 failure: AtomicOpFailure,
186 },
187}
188
189/// A failure of the `atomic_unit` seam itself — the storage layer refused
190/// or could not complete the call at all (read-only backend, no async
191/// runtime for the writer-task lookup, or an [`AtomicUnitOp`] that violated
192/// the suspend-free invariant and got caught by `block_on_sync`). Never
193/// returned for an ordinary op-level guard or SQL failure inside the unit —
194/// those surface as `AtomicRunOutcome::RolledBack`, not this error.
195#[derive(Debug)]
196pub struct AtomicRunnerError(pub StorageError);
197
198impl std::fmt::Display for AtomicRunnerError {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 write!(f, "atomic_unit seam failure: {}", self.0)
201 }
202}
203
204impl std::error::Error for AtomicRunnerError {
205 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
206 Some(&self.0)
207 }
208}
209
210fn raw_stmt(sql: String) -> SqlStatement {
211 SqlStatement {
212 sql,
213 params: vec![],
214 label: None,
215 }
216}
217
218async fn begin_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
219 writer
220 .execute(raw_stmt(format!("SAVEPOINT {name}")))
221 .await
222 .map(|_| ())
223}
224
225async fn release_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
226 writer
227 .execute(raw_stmt(format!("RELEASE {name}")))
228 .await
229 .map(|_| ())
230}
231
232async fn rollback_to_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
233 writer
234 .execute(raw_stmt(format!("ROLLBACK TO {name}")))
235 .await
236 .map(|_| ())
237}
238
239/// Apply one op's plan statements in order, checking each guarded
240/// statement's affected-row count before moving to the next (ADR-099 D1
241/// rule 2). Returns on the first statement that either errors or fails its
242/// guard — never applies a later statement once an earlier one in the same
243/// plan has failed.
244async fn apply_plan(
245 writer: &mut dyn SqlWriter,
246 plan: &AtomicOpPlan,
247) -> Result<(), AtomicOpFailure> {
248 for stmt in plan.plan_statements() {
249 let label = stmt.statement.label.clone();
250 let affected =
251 writer
252 .execute(stmt.statement)
253 .await
254 .map_err(|e| AtomicOpFailure::SqlError {
255 statement_label: label.clone(),
256 message: e.to_string(),
257 })?;
258 if let Some(guard) = stmt.guard {
259 if !guard.holds_for(affected) {
260 return Err(AtomicOpFailure::GuardFailed {
261 statement_label: label,
262 expected: guard,
263 observed: affected,
264 });
265 }
266 }
267 }
268 Ok(())
269}
270
271/// Run `plans` as ONE atomic unit (ADR-099 D1 commit pass): open a single
272/// [`SqlAccess::atomic_unit`], apply every plan's statements under a named
273/// `SAVEPOINT` (`adr099_atomic_op_<n>`), and commit all or roll back all.
274///
275/// **This is the seam the atomic-unit suspend-free invariant governs (see
276/// the module doc comment above).** The closure built here drives only
277/// `AtomicOpPlan::plan_statements` — plain DML — against the writer
278/// `atomic_unit` hands it; it issues no transaction control of its own
279/// (`BEGIN`/`COMMIT`/`ROLLBACK` are owned entirely by `atomic_unit`, exactly
280/// like the existing `execute_batch` contract) beyond the per-op
281/// `SAVEPOINT`/`RELEASE`/`ROLLBACK TO` statements, which are themselves
282/// synchronous DML from SQLite's perspective, not transaction boundaries the
283/// storage layer needs to track.
284///
285/// On the first op whose plan fails (a guard mismatch or a genuine SQL
286/// error), this function does **not** propagate a generic storage error for
287/// that case: it unwinds just that op's own `SAVEPOINT` (best-effort — the
288/// outer `atomic_unit` transaction is rolled back in full regardless, per
289/// ADR-099 D1) and returns `Ok(AtomicRunOutcome::RolledBack { .. })` naming
290/// the failing op and why. [`AtomicRunnerError`] is reserved for a failure
291/// of the `atomic_unit` seam itself — the storage layer refusing or being
292/// unable to run the call at all.
293pub async fn run_atomic_unit(
294 access: &dyn SqlAccess,
295 plans: Vec<AtomicOpPlan>,
296) -> Result<AtomicRunOutcome, AtomicRunnerError> {
297 let failure_slot: Arc<Mutex<Option<(usize, AtomicOpFailure)>>> = Arc::new(Mutex::new(None));
298 let failure_slot_for_closure = Arc::clone(&failure_slot);
299
300 let op: AtomicUnitOp = Box::new(move |writer| {
301 Box::pin(async move {
302 let mut post_commit = Vec::new();
303 for (op_index, plan) in plans.iter().enumerate() {
304 let savepoint = format!("adr099_atomic_op_{op_index}");
305 begin_savepoint(writer, &savepoint).await?;
306 match apply_plan(writer, plan).await {
307 Ok(()) => {
308 release_savepoint(writer, &savepoint).await?;
309 if let Some(effect) = plan.post_commit_effect() {
310 post_commit.push(effect);
311 }
312 }
313 Err(failure) => {
314 // Best-effort unwind of just this op's own partial
315 // DML. The outer `atomic_unit` transaction is rolled
316 // back in full once this closure returns `Err`
317 // regardless (ADR-099 D1: "the whole unit rolls
318 // back") — a failure in this cleanup step is not
319 // itself a correctness gap in the no-partial-state
320 // guarantee, only a diagnostic nicety.
321 let _ = rollback_to_savepoint(writer, &savepoint).await;
322 let _ = release_savepoint(writer, &savepoint).await;
323 *failure_slot_for_closure
324 .lock()
325 .expect("atomic runner failure slot poisoned") =
326 Some((op_index, failure));
327 return Err(StorageError::Internal(format!(
328 "ADR-099 atomic unit aborted at op {op_index}"
329 )));
330 }
331 }
332 }
333 Ok(Box::new(post_commit) as Box<dyn Any + Send>)
334 })
335 });
336
337 match access.atomic_unit(op).await {
338 Ok(boxed) => {
339 let post_commit = *boxed.downcast::<Vec<PostCommitEffect>>().expect(
340 "run_atomic_unit's own closure always returns Box<Vec<PostCommitEffect>> on Ok",
341 );
342 Ok(AtomicRunOutcome::Committed { post_commit })
343 }
344 Err(storage_err) => {
345 let recorded = failure_slot
346 .lock()
347 .expect("atomic runner failure slot poisoned")
348 .take();
349 match recorded {
350 Some((failed_op_index, failure)) => Ok(AtomicRunOutcome::RolledBack {
351 failed_op_index,
352 failure,
353 }),
354 None => Err(AtomicRunnerError(storage_err)),
355 }
356 }
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 use std::sync::Arc as StdArc;
365
366 use khive_db::{ConnectionPool, PoolConfig, SqlBridge};
367 use khive_storage::types::{SqlValue, StorageResult as StorageResultAlias};
368 use uuid::Uuid;
369
370 /// A scratch pool wired exactly like the daemon.rs / sql_bridge.rs
371 /// tests above it: file-backed (atomic_unit's single-writer path is
372 /// only reachable file-backed), `write_queue_enabled: true` so
373 /// `atomic_unit` routes through the real `WriterTask` + `block_on_sync`
374 /// seam rather than the flag-off manual-transaction fallback — the
375 /// suspend-trap contract only fires on this path.
376 fn scratch_pool(name: &str) -> StdArc<ConnectionPool> {
377 let dir = tempfile::tempdir().expect("tempdir");
378 let path = dir.path().join(format!("{name}.db"));
379 let pool = StdArc::new(
380 ConnectionPool::new(PoolConfig {
381 path: Some(path),
382 write_queue_enabled: true,
383 ..PoolConfig::default()
384 })
385 .expect("pool open"),
386 );
387 // Leak the tempdir so the file lives for the pool's lifetime within
388 // one test function — every test here is single-scoped and short.
389 std::mem::forget(dir);
390 pool
391 }
392
393 /// Minimal real schema slice (`entities`, `graph_edges`) — copied from
394 /// `crates/khive-db/sql/entities-ddl.sql` / `graph-ddl.sql` rather than
395 /// invented ad hoc, since the dangling-edge and merge-rewire tests below
396 /// depend on `graph_edges` having no foreign-key enforcement (ADR-099
397 /// D1: "`graph_edges` has no foreign-key enforcement ... SQLite will
398 /// happily commit the inconsistency" absent the guard/predicate rules
399 /// this runner implements).
400 fn seed_schema(pool: &ConnectionPool) {
401 let writer = pool.try_writer().expect("writer");
402 writer
403 .conn()
404 .execute_batch(
405 "CREATE TABLE entities (
406 id TEXT PRIMARY KEY,
407 namespace TEXT NOT NULL,
408 kind TEXT NOT NULL,
409 entity_type TEXT,
410 name TEXT NOT NULL,
411 description TEXT,
412 properties TEXT,
413 tags TEXT NOT NULL DEFAULT '[]',
414 created_at INTEGER NOT NULL,
415 updated_at INTEGER NOT NULL,
416 deleted_at INTEGER,
417 merged_into TEXT,
418 merge_event_id TEXT
419 );
420 CREATE TABLE graph_edges (
421 namespace TEXT NOT NULL,
422 id TEXT NOT NULL,
423 source_id TEXT NOT NULL,
424 target_id TEXT NOT NULL,
425 relation TEXT NOT NULL,
426 weight REAL NOT NULL DEFAULT 1.0,
427 created_at INTEGER NOT NULL,
428 updated_at INTEGER NOT NULL,
429 deleted_at INTEGER,
430 metadata TEXT,
431 target_backend TEXT,
432 PRIMARY KEY (namespace, id)
433 );",
434 )
435 .expect("seed schema");
436 }
437
438 fn insert_entity(pool: &ConnectionPool, id: Uuid, name: &str) {
439 let writer = pool.try_writer().expect("writer");
440 writer
441 .conn()
442 .execute(
443 "INSERT INTO entities \
444 (id, namespace, kind, name, created_at, updated_at) \
445 VALUES (?1, 'local', 'concept', ?2, 0, 0)",
446 rusqlite::params![id.to_string(), name],
447 )
448 .expect("insert entity");
449 }
450
451 fn entity_exists(pool: &ConnectionPool, id: Uuid) -> bool {
452 let writer = pool.try_writer().expect("writer");
453 let count: i64 = writer
454 .conn()
455 .query_row(
456 "SELECT COUNT(*) FROM entities WHERE id = ?1 AND deleted_at IS NULL",
457 rusqlite::params![id.to_string()],
458 |row| row.get(0),
459 )
460 .expect("query entity");
461 count > 0
462 }
463
464 fn edge_count(pool: &ConnectionPool, source: Uuid, target: Uuid) -> i64 {
465 let writer = pool.try_writer().expect("writer");
466 writer
467 .conn()
468 .query_row(
469 "SELECT COUNT(*) FROM graph_edges \
470 WHERE source_id = ?1 AND target_id = ?2 AND deleted_at IS NULL",
471 rusqlite::params![source.to_string(), target.to_string()],
472 |row| row.get(0),
473 )
474 .expect("query edge")
475 }
476
477 fn entities_snapshot(pool: &ConnectionPool) -> Vec<String> {
478 let writer = pool.try_writer().expect("writer");
479 let mut stmt = writer
480 .conn()
481 .prepare("SELECT id, name, deleted_at FROM entities ORDER BY id")
482 .expect("prepare snapshot");
483 let rows = stmt
484 .query_map([], |row| {
485 let id: String = row.get(0)?;
486 let name: String = row.get(1)?;
487 let deleted_at: Option<i64> = row.get(2)?;
488 Ok(format!("{id}:{name}:{deleted_at:?}"))
489 })
490 .expect("query snapshot");
491 rows.collect::<Result<Vec<_>, _>>()
492 .expect("collect snapshot")
493 }
494
495 fn delete_plan(id: Uuid, label: &str) -> AtomicOpPlan {
496 AtomicOpPlan::Delete(DeletePlan {
497 target_id: id,
498 statements: vec![PlanStatement {
499 statement: SqlStatement {
500 sql: "DELETE FROM entities WHERE id = ?1 AND deleted_at IS NULL".to_string(),
501 params: vec![SqlValue::Text(id.to_string())],
502 label: Some(label.to_string()),
503 },
504 guard: Some(AffectedRowGuard::exactly(1)),
505 }],
506 post_commit: PostCommitEffect::None,
507 })
508 }
509
510 fn rename_plan(id: Uuid, new_name: &str, label: &str) -> AtomicOpPlan {
511 AtomicOpPlan::Update(UpdatePlan {
512 target_id: id,
513 statements: vec![PlanStatement {
514 statement: SqlStatement {
515 sql: "UPDATE entities SET name = ?1, updated_at = 1 \
516 WHERE id = ?2 AND deleted_at IS NULL"
517 .to_string(),
518 params: vec![
519 SqlValue::Text(new_name.to_string()),
520 SqlValue::Text(id.to_string()),
521 ],
522 label: Some(label.to_string()),
523 },
524 guard: Some(AffectedRowGuard::exactly(1)),
525 }],
526 post_commit: PostCommitEffect::None,
527 edge_natural_key: None,
528 })
529 }
530
531 /// The guarded `INSERT ... SELECT ... WHERE EXISTS` shape `LinkPlan`
532 /// documents: the affected-row count of this ONE statement doubles as
533 /// the in-transaction endpoint-existence probe (ADR-099 acceptance
534 /// criteria: dangling-edge).
535 fn link_plan(edge_id: Uuid, source: Uuid, target: Uuid) -> AtomicOpPlan {
536 AtomicOpPlan::Link(LinkPlan {
537 source_id: source,
538 target_id: target,
539 statement: PlanStatement {
540 statement: SqlStatement {
541 sql: "INSERT INTO graph_edges \
542 (namespace, id, source_id, target_id, relation, created_at, updated_at) \
543 SELECT 'local', ?1, ?2, ?3, 'annotates', 0, 0 \
544 WHERE EXISTS (SELECT 1 FROM entities WHERE id = ?2 AND deleted_at IS NULL) \
545 AND EXISTS (SELECT 1 FROM entities WHERE id = ?3 AND deleted_at IS NULL)"
546 .to_string(),
547 params: vec![
548 SqlValue::Text(edge_id.to_string()),
549 SqlValue::Text(source.to_string()),
550 SqlValue::Text(target.to_string()),
551 ],
552 label: Some("insert-edge-where-exists".to_string()),
553 },
554 guard: Some(AffectedRowGuard::exactly(1)),
555 },
556 })
557 }
558
559 fn merge_plan(into_id: Uuid, from_id: Uuid) -> AtomicOpPlan {
560 AtomicOpPlan::Merge(MergePlan {
561 into_id,
562 from_id,
563 rewires: vec![crate::atomic_plan::PlanPredicate {
564 description: "source_id = :from".to_string(),
565 statement: SqlStatement {
566 sql: "UPDATE graph_edges SET source_id = ?1, updated_at = 1 \
567 WHERE source_id = ?2"
568 .to_string(),
569 params: vec![
570 SqlValue::Text(into_id.to_string()),
571 SqlValue::Text(from_id.to_string()),
572 ],
573 label: Some("merge-rewire".to_string()),
574 },
575 }],
576 lifecycle: vec![PlanStatement {
577 statement: SqlStatement {
578 sql: "UPDATE entities SET deleted_at = 1, merged_into = ?1 \
579 WHERE id = ?2 AND deleted_at IS NULL"
580 .to_string(),
581 params: vec![
582 SqlValue::Text(into_id.to_string()),
583 SqlValue::Text(from_id.to_string()),
584 ],
585 label: Some("tombstone-from-entity".to_string()),
586 },
587 guard: Some(AffectedRowGuard::exactly(1)),
588 }],
589 })
590 }
591
592 // ------------------------------------------------------------------
593 // 1. Rollback end-to-end
594 // ------------------------------------------------------------------
595
596 #[tokio::test]
597 async fn rollback_end_to_end_leaves_zero_partial_state() {
598 let pool = scratch_pool("rollback_end_to_end");
599 seed_schema(&pool);
600 let a = Uuid::new_v4();
601 let b = Uuid::new_v4();
602 insert_entity(&pool, a, "alpha");
603 insert_entity(&pool, b, "bravo");
604 let before = entities_snapshot(&pool);
605
606 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
607 let plans = vec![
608 rename_plan(a, "alpha-renamed", "rename-a"),
609 // Induced failure: b does not exist under this id, so the
610 // guard (expects exactly 1 affected row) fails.
611 delete_plan(Uuid::new_v4(), "delete-nonexistent"),
612 rename_plan(b, "bravo-renamed", "rename-b"),
613 ];
614
615 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
616 match outcome {
617 AtomicRunOutcome::RolledBack {
618 failed_op_index,
619 failure,
620 } => {
621 assert_eq!(failed_op_index, 1);
622 assert!(matches!(failure, AtomicOpFailure::GuardFailed { .. }));
623 }
624 other => panic!("expected RolledBack, got {other:?}"),
625 }
626
627 let after = entities_snapshot(&pool);
628 assert_eq!(
629 before, after,
630 "a mid-unit failure must leave the database byte-for-byte unchanged"
631 );
632 }
633
634 // ------------------------------------------------------------------
635 // 2. Suspend-trap paired: the runner's happy path resolves on first
636 // poll (commits); a hand-built suspending closure through the SAME
637 // `atomic_unit` seam fails loudly instead of silently succeeding.
638 // ------------------------------------------------------------------
639
640 #[tokio::test]
641 async fn commit_pass_resolves_on_first_poll_and_commits() {
642 let pool = scratch_pool("suspend_trap_happy_path");
643 seed_schema(&pool);
644 let a = Uuid::new_v4();
645 insert_entity(&pool, a, "alpha");
646
647 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
648 let plans = vec![rename_plan(a, "alpha-v2", "rename-a")];
649
650 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
651 assert!(
652 matches!(outcome, AtomicRunOutcome::Committed { .. }),
653 "the real commit-pass closure (SAVEPOINT + guarded DML only) must \
654 resolve on block_on_sync's first poll and commit: {outcome:?}"
655 );
656
657 let writer = pool.try_writer().expect("writer");
658 let name: String = writer
659 .conn()
660 .query_row(
661 "SELECT name FROM entities WHERE id = ?1",
662 rusqlite::params![a.to_string()],
663 |row| row.get(0),
664 )
665 .expect("query renamed entity");
666 assert_eq!(name, "alpha-v2");
667 }
668
669 #[tokio::test]
670 async fn hand_built_suspending_closure_fails_loudly_through_the_same_seam() {
671 // The exact misuse the module-doc "suspend-trap" promise guards
672 // against: a closure sent through this crate's OWN integration of
673 // `SqlAccess::atomic_unit` (the same `bridge` type `run_atomic_unit`
674 // uses) that reaches a real suspension point instead of staying
675 // synchronous DML — a stand-in for "a future edit admits a verb
676 // whose apply forgot to hoist its embedding out of the transaction"
677 // (ADR-099 acceptance criteria). `tokio::task::yield_now` returns
678 // `Poll::Pending` on its first poll by construction, so this is a
679 // real suspension, not `std::future::pending`'s permanent one.
680 let pool = scratch_pool("suspend_trap_misuse");
681 seed_schema(&pool);
682
683 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
684 let suspending_op: AtomicUnitOp = Box::new(|_writer| {
685 Box::pin(async move {
686 tokio::task::yield_now().await;
687 Ok(Box::new(()) as Box<dyn Any + Send>)
688 })
689 });
690
691 let result: StorageResultAlias<Box<dyn Any + Send>> =
692 khive_storage::SqlAccess::atomic_unit(&bridge, suspending_op).await;
693 assert!(
694 result.is_err(),
695 "a closure that suspends on first poll must fail loudly through \
696 `atomic_unit`, never silently succeed or wedge; got {result:?}"
697 );
698 }
699
700 // ------------------------------------------------------------------
701 // 3. Staleness both directions
702 // ------------------------------------------------------------------
703
704 #[tokio::test]
705 async fn concurrent_mutation_between_prepare_and_apply_trips_guard_and_rolls_back() {
706 let pool = scratch_pool("staleness_tripped");
707 seed_schema(&pool);
708 let a = Uuid::new_v4();
709 insert_entity(&pool, a, "alpha");
710
711 // Plan prepared against pre-transaction state (a exists).
712 let plan = rename_plan(a, "alpha-v2", "rename-a");
713
714 // Concurrent mutation between prepare and apply: a is deleted by a
715 // separate writer before the commit pass runs.
716 {
717 let writer = pool.try_writer().expect("writer");
718 writer
719 .conn()
720 .execute(
721 "DELETE FROM entities WHERE id = ?1",
722 rusqlite::params![a.to_string()],
723 )
724 .expect("concurrent delete");
725 }
726
727 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
728 let outcome = run_atomic_unit(&bridge, vec![plan])
729 .await
730 .expect("seam call ok");
731 match outcome {
732 AtomicRunOutcome::RolledBack {
733 failed_op_index,
734 failure,
735 } => {
736 assert_eq!(failed_op_index, 0);
737 assert!(matches!(
738 failure,
739 AtomicOpFailure::GuardFailed { observed: 0, .. }
740 ));
741 }
742 other => panic!("expected RolledBack from the stale guard, got {other:?}"),
743 }
744 }
745
746 #[tokio::test]
747 async fn no_mutation_twin_commits() {
748 let pool = scratch_pool("staleness_untripped");
749 seed_schema(&pool);
750 let a = Uuid::new_v4();
751 insert_entity(&pool, a, "alpha");
752
753 let plan = rename_plan(a, "alpha-v2", "rename-a");
754 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
755 let outcome = run_atomic_unit(&bridge, vec![plan])
756 .await
757 .expect("seam call ok");
758 assert!(matches!(outcome, AtomicRunOutcome::Committed { .. }));
759 assert!(entity_exists(&pool, a));
760 }
761
762 // ------------------------------------------------------------------
763 // 4. Dangling-edge
764 // ------------------------------------------------------------------
765
766 #[tokio::test]
767 async fn dangling_edge_from_delete_then_link_rolls_back_whole_unit() {
768 let pool = scratch_pool("dangling_edge");
769 seed_schema(&pool);
770 let x = Uuid::new_v4();
771 let a = Uuid::new_v4();
772 insert_entity(&pool, x, "x");
773 insert_entity(&pool, a, "a");
774 let before = entities_snapshot(&pool);
775
776 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
777 let edge_id = Uuid::new_v4();
778 let plans = vec![delete_plan(x, "delete-x"), link_plan(edge_id, a, x)];
779
780 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
781 match outcome {
782 AtomicRunOutcome::RolledBack {
783 failed_op_index,
784 failure,
785 } => {
786 assert_eq!(
787 failed_op_index, 1,
788 "delete(x) succeeds; link(a, x) is the op whose \
789 endpoint-existence guard fails once x is gone"
790 );
791 assert!(matches!(
792 failure,
793 AtomicOpFailure::GuardFailed { observed: 0, .. }
794 ));
795 }
796 other => panic!("expected RolledBack, got {other:?}"),
797 }
798
799 assert_eq!(
800 edge_count(&pool, a, x),
801 0,
802 "no dangling edge may be committed"
803 );
804 assert_eq!(
805 entities_snapshot(&pool),
806 before,
807 "x must still exist — the whole unit rolled back, including the delete"
808 );
809 }
810
811 // ------------------------------------------------------------------
812 // 5. Merge-rewire
813 // ------------------------------------------------------------------
814
815 #[tokio::test]
816 async fn merge_rewire_sees_earlier_in_file_edge_write() {
817 let pool = scratch_pool("merge_rewire");
818 seed_schema(&pool);
819 let z = Uuid::new_v4();
820 let from = Uuid::new_v4();
821 let into = Uuid::new_v4();
822 insert_entity(&pool, z, "z");
823 insert_entity(&pool, from, "from-entity");
824 insert_entity(&pool, into, "into-entity");
825
826 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
827 let edge_id = Uuid::new_v4();
828 // [link(from, z), merge(into, from)] — the rewire's predicate-based
829 // UPDATE must see the edge the earlier op in THIS SAME unit
830 // inserted (ADR-099 acceptance criteria: "merge rewires see earlier
831 // in-file writes").
832 let plans = vec![link_plan(edge_id, from, z), merge_plan(into, from)];
833
834 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
835 assert!(
836 matches!(outcome, AtomicRunOutcome::Committed { .. }),
837 "expected the unit to commit: {outcome:?}"
838 );
839
840 assert_eq!(
841 edge_count(&pool, from, z),
842 0,
843 "no live edge may remain sourced from the merged-away entity"
844 );
845 assert_eq!(
846 edge_count(&pool, into, z),
847 1,
848 "the edge must be rewired onto `into`"
849 );
850 assert!(
851 !entity_exists(&pool, from),
852 "the `from` entity must be tombstoned (soft-deleted)"
853 );
854 }
855
856 // ------------------------------------------------------------------
857 // 6. Zero-row
858 // ------------------------------------------------------------------
859
860 #[tokio::test]
861 async fn zero_row_apply_fails_the_whole_unit() {
862 let pool = scratch_pool("zero_row");
863 seed_schema(&pool);
864 let x = Uuid::new_v4();
865 insert_entity(&pool, x, "x");
866 let before = entities_snapshot(&pool);
867
868 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
869 // [delete(x, hard), update(x)] — update's affected-row guard
870 // observes zero rows once x is already gone.
871 let plans = vec![
872 delete_plan(x, "delete-x"),
873 rename_plan(x, "x-v2", "update-x"),
874 ];
875
876 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
877 match outcome {
878 AtomicRunOutcome::RolledBack {
879 failed_op_index,
880 failure,
881 } => {
882 assert_eq!(failed_op_index, 1);
883 assert_eq!(
884 failure,
885 AtomicOpFailure::GuardFailed {
886 statement_label: Some("update-x".to_string()),
887 expected: AffectedRowGuard::exactly(1),
888 observed: 0,
889 }
890 );
891 }
892 other => panic!("expected RolledBack, got {other:?}"),
893 }
894
895 assert_eq!(
896 entities_snapshot(&pool),
897 before,
898 "x must be restored — the whole unit rolled back"
899 );
900 }
901
902 // ------------------------------------------------------------------
903 // Post-commit effect collection (UpdatePlan's reindex caveat, D3)
904 // ------------------------------------------------------------------
905
906 // ------------------------------------------------------------------
907 // 7. Daemon coexistence (ADR-099 B3 acceptance test 5)
908 // ------------------------------------------------------------------
909
910 /// An atomic unit must go through the SAME `WriterTaskHandle` queue as
911 /// an ordinary single-row write — not a separate connection that would
912 /// let it silently bypass single-writer serialization (ADR-067
913 /// Component A). This reuses the deterministic occupier pattern from
914 /// `khive-db::stores::graph_tests::upsert_edge_routes_through_writer_task_when_flag_enabled`
915 /// (Slice A): an occupier closure parks on a oneshot inside the writer
916 /// task's one drain slot, so both the atomic unit's `atomic_unit` call
917 /// and a concurrent ordinary `upsert_entity` call are forced to queue
918 /// behind it. `queue_depth` reaching 2 while both are pending, then
919 /// draining to 0 once the occupier releases and both complete, is the
920 /// proof that `run_atomic_unit` "discriminates" through the real queue
921 /// gauge rather than opening a competing connection.
922 #[tokio::test]
923 async fn atomic_unit_and_concurrent_normal_write_share_the_same_writer_queue() {
924 let pool = scratch_pool("daemon_coexistence");
925 seed_schema(&pool);
926 let a = Uuid::new_v4();
927 let b = Uuid::new_v4();
928 insert_entity(&pool, a, "alpha");
929
930 let writer_task = pool
931 .writer_task_handle()
932 .expect("writer task lookup")
933 .expect("writer task must be spawned with the flag on for a file-backed pool");
934
935 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
936 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
937 let occupier = {
938 let writer_task = writer_task.clone();
939 tokio::spawn(async move {
940 writer_task
941 .send(move |_conn| {
942 let _ = started_tx.send(());
943 let _ = release_rx.blocking_recv();
944 Ok::<(), StorageError>(())
945 })
946 .await
947 })
948 };
949
950 started_rx
951 .await
952 .expect("occupier must signal it has started running inside the writer task");
953 assert_eq!(
954 writer_task.queue_depth(),
955 0,
956 "channel must start empty once the occupier has been dequeued and is running"
957 );
958
959 // The atomic unit: renames `a`.
960 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
961 let atomic_plans = vec![rename_plan(a, "alpha-atomic", "rename-a-atomic")];
962 let atomic_task = tokio::spawn(async move { run_atomic_unit(&bridge, atomic_plans).await });
963
964 // A concurrent ORDINARY write, routed through the SAME
965 // `WriterTaskHandle::send` the real `with_writer` path uses
966 // (khive-db's own `graph_tests` occupier test asserts this for
967 // `upsert_edge`) — NOT a second `pool.try_writer()` connection,
968 // which would open a competing SQLite handle and contend on the
969 // file lock the occupier's `BEGIN IMMEDIATE` already holds,
970 // producing a `DatabaseBusy` false failure unrelated to the queue
971 // this test is actually proving something about.
972 let normal_write_task = {
973 let writer_task = writer_task.clone();
974 let b_str = b.to_string();
975 tokio::spawn(async move {
976 writer_task
977 .send(move |conn| {
978 conn.execute(
979 "INSERT INTO entities \
980 (id, namespace, kind, name, created_at, updated_at) \
981 VALUES (?1, 'local', 'concept', ?2, 0, 0)",
982 rusqlite::params![b_str, "bravo"],
983 )
984 .map_err(|e| StorageError::Internal(e.to_string()))
985 })
986 .await
987 })
988 };
989
990 // Both the atomic unit's `atomic_unit` call and (best-effort) the
991 // ordinary write must appear in the SAME queue while the occupier
992 // holds the slot — this is the coexistence proof: neither one opens
993 // a side-channel connection that would let it skip the queue.
994 let mut saw_both_enqueued = false;
995 for _ in 0..200 {
996 if writer_task.queue_depth() >= 2 {
997 saw_both_enqueued = true;
998 break;
999 }
1000 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1001 }
1002 assert!(
1003 saw_both_enqueued,
1004 "expected BOTH the atomic unit's atomic_unit() call and the \
1005 concurrent ordinary write to appear in the writer task's queue \
1006 while the occupier held the single drain slot (queue_depth \
1007 should have reached 2) — observed {}; run_atomic_unit is not \
1008 sharing the same queue as an ordinary write",
1009 writer_task.queue_depth()
1010 );
1011
1012 release_tx.send(()).expect("release occupier");
1013 occupier
1014 .await
1015 .expect("occupier task join")
1016 .expect("occupier op ok");
1017
1018 let atomic_outcome = atomic_task
1019 .await
1020 .expect("atomic task join")
1021 .expect("seam call ok");
1022 assert!(
1023 matches!(atomic_outcome, AtomicRunOutcome::Committed { .. }),
1024 "atomic unit must commit once the occupier releases: {atomic_outcome:?}"
1025 );
1026 normal_write_task
1027 .await
1028 .expect("normal write task join")
1029 .expect("normal write op ok");
1030
1031 assert!(
1032 entity_exists(&pool, a),
1033 "a must exist (renamed) after commit"
1034 );
1035 {
1036 // Scoped: the `WriterGuard` returned by `try_writer()` holds a
1037 // non-reentrant `parking_lot::Mutex` for as long as it lives.
1038 // Left unscoped, it would still be held when `entity_exists`
1039 // below tries to check out the same writer, deadlocking (in
1040 // practice, timing out after `checkout_timeout`) against itself
1041 // rather than against anything the writer task/occupier hold.
1042 let writer = pool.try_writer().expect("writer");
1043 let renamed: String = writer
1044 .conn()
1045 .query_row(
1046 "SELECT name FROM entities WHERE id = ?1",
1047 rusqlite::params![a.to_string()],
1048 |row| row.get(0),
1049 )
1050 .expect("query renamed entity");
1051 assert_eq!(renamed, "alpha-atomic");
1052 }
1053 assert!(
1054 entity_exists(&pool, b),
1055 "the concurrent ordinary write must also have landed"
1056 );
1057 }
1058
1059 #[tokio::test]
1060 async fn post_commit_effects_are_collected_in_op_order_and_only_for_update() {
1061 let pool = scratch_pool("post_commit_collection");
1062 seed_schema(&pool);
1063 let a = Uuid::new_v4();
1064 let b = Uuid::new_v4();
1065 insert_entity(&pool, a, "alpha");
1066 insert_entity(&pool, b, "bravo");
1067
1068 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
1069 let mut update_a = match rename_plan(a, "alpha-v2", "rename-a") {
1070 AtomicOpPlan::Update(p) => p,
1071 _ => unreachable!(),
1072 };
1073 update_a.post_commit = PostCommitEffect::ReindexEntity { entity_id: a };
1074 let plans = vec![
1075 AtomicOpPlan::Update(update_a),
1076 delete_plan(b, "delete-b"), // no post-commit effect for delete
1077 ];
1078
1079 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
1080 match outcome {
1081 AtomicRunOutcome::Committed { post_commit } => {
1082 assert_eq!(
1083 post_commit,
1084 vec![PostCommitEffect::ReindexEntity { entity_id: a }]
1085 );
1086 }
1087 other => panic!("expected Committed, got {other:?}"),
1088 }
1089 }
1090}