1use std::any::Any;
29use std::sync::{Arc, Mutex};
30
31use khive_storage::{AtomicUnitOp, SqlAccess, SqlStatement, SqlWriter, StorageError};
32
33use crate::atomic_plan::{
34 AddEntityPlan, AddNotePlan, AffectedRowGuard, DeletePlan, GovernancePlan, GtdCompletePlan,
35 GtdTransitionPlan, LinkPlan, MergePlan, PlanStatement, PostCommitEffect, UpdatePlan,
36};
37
38#[derive(Debug, Clone)]
45pub enum AtomicOpPlan {
46 AddEntity(AddEntityPlan),
47 AddNote(AddNotePlan),
48 Update(UpdatePlan),
49 Delete(DeletePlan),
50 Link(LinkPlan),
51 Merge(MergePlan),
52 GtdTransition(GtdTransitionPlan),
53 GtdComplete(GtdCompletePlan),
54 Governance(GovernancePlan),
55}
56
57impl AtomicOpPlan {
58 fn plan_statements(&self) -> Vec<PlanStatement> {
67 match self {
68 AtomicOpPlan::AddEntity(p) => p.statements.clone(),
69 AtomicOpPlan::AddNote(p) => p.statements.clone(),
70 AtomicOpPlan::Update(p) => p.statements.clone(),
71 AtomicOpPlan::Delete(p) => p.statements.clone(),
72 AtomicOpPlan::Link(p) => vec![p.statement.clone()],
73 AtomicOpPlan::Merge(p) => {
74 let mut statements: Vec<PlanStatement> = p
75 .rewires
76 .iter()
77 .map(|rewire| PlanStatement {
78 statement: rewire.statement.clone(),
79 guard: None,
80 })
81 .collect();
82 statements.extend(p.lifecycle.clone());
83 statements
84 }
85 AtomicOpPlan::GtdTransition(p) => p.statements.clone(),
86 AtomicOpPlan::GtdComplete(p) => p.statements.clone(),
87 AtomicOpPlan::Governance(p) => p.statements.clone(),
88 }
89 }
90
91 fn post_commit_effect(&self) -> Option<PostCommitEffect> {
105 match self {
106 AtomicOpPlan::AddEntity(p) if p.post_commit != PostCommitEffect::None => {
107 Some(p.post_commit.clone())
108 }
109 AtomicOpPlan::AddNote(p) if p.post_commit != PostCommitEffect::None => {
110 Some(p.post_commit.clone())
111 }
112 AtomicOpPlan::Update(p) if p.post_commit != PostCommitEffect::None => {
113 Some(p.post_commit.clone())
114 }
115 AtomicOpPlan::Delete(p) if p.post_commit != PostCommitEffect::None => {
116 Some(p.post_commit.clone())
117 }
118 AtomicOpPlan::GtdTransition(p) if p.post_commit != PostCommitEffect::None => {
119 Some(p.post_commit.clone())
120 }
121 AtomicOpPlan::GtdComplete(p) if p.post_commit != PostCommitEffect::None => {
122 Some(p.post_commit.clone())
123 }
124 _ => None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum AtomicOpFailure {
133 GuardFailed {
139 statement_label: Option<String>,
140 expected: AffectedRowGuard,
141 observed: u64,
142 },
143 SqlError {
147 statement_label: Option<String>,
148 message: String,
149 },
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum AtomicRunOutcome {
157 Committed { post_commit: Vec<PostCommitEffect> },
165 RolledBack {
173 failed_op_index: usize,
174 failure: AtomicOpFailure,
175 },
176}
177
178#[derive(Debug)]
185pub struct AtomicRunnerError(pub StorageError);
186
187impl std::fmt::Display for AtomicRunnerError {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 write!(f, "atomic_unit seam failure: {}", self.0)
190 }
191}
192
193impl std::error::Error for AtomicRunnerError {
194 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
195 Some(&self.0)
196 }
197}
198
199fn raw_stmt(sql: String) -> SqlStatement {
200 SqlStatement {
201 sql,
202 params: vec![],
203 label: None,
204 }
205}
206
207async fn begin_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
208 writer
209 .execute(raw_stmt(format!("SAVEPOINT {name}")))
210 .await
211 .map(|_| ())
212}
213
214async fn release_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
215 writer
216 .execute(raw_stmt(format!("RELEASE {name}")))
217 .await
218 .map(|_| ())
219}
220
221async fn rollback_to_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
222 writer
223 .execute(raw_stmt(format!("ROLLBACK TO {name}")))
224 .await
225 .map(|_| ())
226}
227
228async fn apply_plan(
234 writer: &mut dyn SqlWriter,
235 plan: &AtomicOpPlan,
236) -> Result<(), AtomicOpFailure> {
237 for stmt in plan.plan_statements() {
238 let label = stmt.statement.label.clone();
239 let affected =
240 writer
241 .execute(stmt.statement)
242 .await
243 .map_err(|e| AtomicOpFailure::SqlError {
244 statement_label: label.clone(),
245 message: e.to_string(),
246 })?;
247 if let Some(guard) = stmt.guard {
248 if !guard.holds_for(affected) {
249 return Err(AtomicOpFailure::GuardFailed {
250 statement_label: label,
251 expected: guard,
252 observed: affected,
253 });
254 }
255 }
256 }
257 Ok(())
258}
259
260pub async fn run_atomic_unit(
283 access: &dyn SqlAccess,
284 plans: Vec<AtomicOpPlan>,
285) -> Result<AtomicRunOutcome, AtomicRunnerError> {
286 let failure_slot: Arc<Mutex<Option<(usize, AtomicOpFailure)>>> = Arc::new(Mutex::new(None));
287 let failure_slot_for_closure = Arc::clone(&failure_slot);
288
289 let op: AtomicUnitOp = Box::new(move |writer| {
290 Box::pin(async move {
291 let mut post_commit = Vec::new();
292 for (op_index, plan) in plans.iter().enumerate() {
293 let savepoint = format!("adr099_atomic_op_{op_index}");
294 begin_savepoint(writer, &savepoint).await?;
295 match apply_plan(writer, plan).await {
296 Ok(()) => {
297 release_savepoint(writer, &savepoint).await?;
298 if let Some(effect) = plan.post_commit_effect() {
299 post_commit.push(effect);
300 }
301 }
302 Err(failure) => {
303 let _ = rollback_to_savepoint(writer, &savepoint).await;
311 let _ = release_savepoint(writer, &savepoint).await;
312 *failure_slot_for_closure
313 .lock()
314 .expect("atomic runner failure slot poisoned") =
315 Some((op_index, failure));
316 return Err(StorageError::Internal(format!(
317 "ADR-099 atomic unit aborted at op {op_index}"
318 )));
319 }
320 }
321 }
322 Ok(Box::new(post_commit) as Box<dyn Any + Send>)
323 })
324 });
325
326 match access.atomic_unit(op).await {
327 Ok(boxed) => {
328 let post_commit = *boxed.downcast::<Vec<PostCommitEffect>>().expect(
329 "run_atomic_unit's own closure always returns Box<Vec<PostCommitEffect>> on Ok",
330 );
331 Ok(AtomicRunOutcome::Committed { post_commit })
332 }
333 Err(storage_err) => {
334 let recorded = failure_slot
335 .lock()
336 .expect("atomic runner failure slot poisoned")
337 .take();
338 match recorded {
339 Some((failed_op_index, failure)) => Ok(AtomicRunOutcome::RolledBack {
340 failed_op_index,
341 failure,
342 }),
343 None => Err(AtomicRunnerError(storage_err)),
344 }
345 }
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 use std::sync::Arc as StdArc;
354
355 use khive_db::{ConnectionPool, PoolConfig, SqlBridge};
356 use khive_storage::types::{SqlValue, StorageResult as StorageResultAlias};
357 use uuid::Uuid;
358
359 fn scratch_pool(name: &str) -> StdArc<ConnectionPool> {
366 let dir = tempfile::tempdir().expect("tempdir");
367 let path = dir.path().join(format!("{name}.db"));
368 let pool = StdArc::new(
369 ConnectionPool::new(PoolConfig {
370 path: Some(path),
371 write_queue_enabled: true,
372 ..PoolConfig::default()
373 })
374 .expect("pool open"),
375 );
376 std::mem::forget(dir);
379 pool
380 }
381
382 fn seed_schema(pool: &ConnectionPool) {
390 let writer = pool.try_writer().expect("writer");
391 writer
392 .conn()
393 .execute_batch(
394 "CREATE TABLE entities (
395 id TEXT PRIMARY KEY,
396 namespace TEXT NOT NULL,
397 kind TEXT NOT NULL,
398 entity_type TEXT,
399 name TEXT NOT NULL,
400 description TEXT,
401 properties TEXT,
402 tags TEXT NOT NULL DEFAULT '[]',
403 created_at INTEGER NOT NULL,
404 updated_at INTEGER NOT NULL,
405 deleted_at INTEGER,
406 merged_into TEXT,
407 merge_event_id TEXT
408 );
409 CREATE TABLE graph_edges (
410 namespace TEXT NOT NULL,
411 id TEXT NOT NULL,
412 source_id TEXT NOT NULL,
413 target_id TEXT NOT NULL,
414 relation TEXT NOT NULL,
415 weight REAL NOT NULL DEFAULT 1.0,
416 created_at INTEGER NOT NULL,
417 updated_at INTEGER NOT NULL,
418 deleted_at INTEGER,
419 metadata TEXT,
420 target_backend TEXT,
421 PRIMARY KEY (namespace, id)
422 );",
423 )
424 .expect("seed schema");
425 }
426
427 fn insert_entity(pool: &ConnectionPool, id: Uuid, name: &str) {
428 let writer = pool.try_writer().expect("writer");
429 writer
430 .conn()
431 .execute(
432 "INSERT INTO entities \
433 (id, namespace, kind, name, created_at, updated_at) \
434 VALUES (?1, 'local', 'concept', ?2, 0, 0)",
435 rusqlite::params![id.to_string(), name],
436 )
437 .expect("insert entity");
438 }
439
440 fn entity_exists(pool: &ConnectionPool, id: Uuid) -> bool {
441 let writer = pool.try_writer().expect("writer");
442 let count: i64 = writer
443 .conn()
444 .query_row(
445 "SELECT COUNT(*) FROM entities WHERE id = ?1 AND deleted_at IS NULL",
446 rusqlite::params![id.to_string()],
447 |row| row.get(0),
448 )
449 .expect("query entity");
450 count > 0
451 }
452
453 fn edge_count(pool: &ConnectionPool, source: Uuid, target: Uuid) -> i64 {
454 let writer = pool.try_writer().expect("writer");
455 writer
456 .conn()
457 .query_row(
458 "SELECT COUNT(*) FROM graph_edges \
459 WHERE source_id = ?1 AND target_id = ?2 AND deleted_at IS NULL",
460 rusqlite::params![source.to_string(), target.to_string()],
461 |row| row.get(0),
462 )
463 .expect("query edge")
464 }
465
466 fn entities_snapshot(pool: &ConnectionPool) -> Vec<String> {
467 let writer = pool.try_writer().expect("writer");
468 let mut stmt = writer
469 .conn()
470 .prepare("SELECT id, name, deleted_at FROM entities ORDER BY id")
471 .expect("prepare snapshot");
472 let rows = stmt
473 .query_map([], |row| {
474 let id: String = row.get(0)?;
475 let name: String = row.get(1)?;
476 let deleted_at: Option<i64> = row.get(2)?;
477 Ok(format!("{id}:{name}:{deleted_at:?}"))
478 })
479 .expect("query snapshot");
480 rows.collect::<Result<Vec<_>, _>>()
481 .expect("collect snapshot")
482 }
483
484 fn delete_plan(id: Uuid, label: &str) -> AtomicOpPlan {
485 AtomicOpPlan::Delete(DeletePlan {
486 target_id: id,
487 statements: vec![PlanStatement {
488 statement: SqlStatement {
489 sql: "DELETE FROM entities WHERE id = ?1 AND deleted_at IS NULL".to_string(),
490 params: vec![SqlValue::Text(id.to_string())],
491 label: Some(label.to_string()),
492 },
493 guard: Some(AffectedRowGuard::exactly(1)),
494 }],
495 post_commit: PostCommitEffect::None,
496 })
497 }
498
499 fn rename_plan(id: Uuid, new_name: &str, label: &str) -> AtomicOpPlan {
500 AtomicOpPlan::Update(UpdatePlan {
501 target_id: id,
502 statements: vec![PlanStatement {
503 statement: SqlStatement {
504 sql: "UPDATE entities SET name = ?1, updated_at = 1 \
505 WHERE id = ?2 AND deleted_at IS NULL"
506 .to_string(),
507 params: vec![
508 SqlValue::Text(new_name.to_string()),
509 SqlValue::Text(id.to_string()),
510 ],
511 label: Some(label.to_string()),
512 },
513 guard: Some(AffectedRowGuard::exactly(1)),
514 }],
515 post_commit: PostCommitEffect::None,
516 edge_natural_key: None,
517 })
518 }
519
520 fn link_plan(edge_id: Uuid, source: Uuid, target: Uuid) -> AtomicOpPlan {
525 AtomicOpPlan::Link(LinkPlan {
526 source_id: source,
527 target_id: target,
528 statement: PlanStatement {
529 statement: SqlStatement {
530 sql: "INSERT INTO graph_edges \
531 (namespace, id, source_id, target_id, relation, created_at, updated_at) \
532 SELECT 'local', ?1, ?2, ?3, 'annotates', 0, 0 \
533 WHERE EXISTS (SELECT 1 FROM entities WHERE id = ?2 AND deleted_at IS NULL) \
534 AND EXISTS (SELECT 1 FROM entities WHERE id = ?3 AND deleted_at IS NULL)"
535 .to_string(),
536 params: vec![
537 SqlValue::Text(edge_id.to_string()),
538 SqlValue::Text(source.to_string()),
539 SqlValue::Text(target.to_string()),
540 ],
541 label: Some("insert-edge-where-exists".to_string()),
542 },
543 guard: Some(AffectedRowGuard::exactly(1)),
544 },
545 })
546 }
547
548 fn merge_plan(into_id: Uuid, from_id: Uuid) -> AtomicOpPlan {
549 AtomicOpPlan::Merge(MergePlan {
550 into_id,
551 from_id,
552 rewires: vec![crate::atomic_plan::PlanPredicate {
553 description: "source_id = :from".to_string(),
554 statement: SqlStatement {
555 sql: "UPDATE graph_edges SET source_id = ?1, updated_at = 1 \
556 WHERE source_id = ?2"
557 .to_string(),
558 params: vec![
559 SqlValue::Text(into_id.to_string()),
560 SqlValue::Text(from_id.to_string()),
561 ],
562 label: Some("merge-rewire".to_string()),
563 },
564 }],
565 lifecycle: vec![PlanStatement {
566 statement: SqlStatement {
567 sql: "UPDATE entities SET deleted_at = 1, merged_into = ?1 \
568 WHERE id = ?2 AND deleted_at IS NULL"
569 .to_string(),
570 params: vec![
571 SqlValue::Text(into_id.to_string()),
572 SqlValue::Text(from_id.to_string()),
573 ],
574 label: Some("tombstone-from-entity".to_string()),
575 },
576 guard: Some(AffectedRowGuard::exactly(1)),
577 }],
578 })
579 }
580
581 #[tokio::test]
586 async fn rollback_end_to_end_leaves_zero_partial_state() {
587 let pool = scratch_pool("rollback_end_to_end");
588 seed_schema(&pool);
589 let a = Uuid::new_v4();
590 let b = Uuid::new_v4();
591 insert_entity(&pool, a, "alpha");
592 insert_entity(&pool, b, "bravo");
593 let before = entities_snapshot(&pool);
594
595 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
596 let plans = vec![
597 rename_plan(a, "alpha-renamed", "rename-a"),
598 delete_plan(Uuid::new_v4(), "delete-nonexistent"),
601 rename_plan(b, "bravo-renamed", "rename-b"),
602 ];
603
604 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
605 match outcome {
606 AtomicRunOutcome::RolledBack {
607 failed_op_index,
608 failure,
609 } => {
610 assert_eq!(failed_op_index, 1);
611 assert!(matches!(failure, AtomicOpFailure::GuardFailed { .. }));
612 }
613 other => panic!("expected RolledBack, got {other:?}"),
614 }
615
616 let after = entities_snapshot(&pool);
617 assert_eq!(
618 before, after,
619 "a mid-unit failure must leave the database byte-for-byte unchanged"
620 );
621 }
622
623 #[tokio::test]
630 async fn commit_pass_resolves_on_first_poll_and_commits() {
631 let pool = scratch_pool("suspend_trap_happy_path");
632 seed_schema(&pool);
633 let a = Uuid::new_v4();
634 insert_entity(&pool, a, "alpha");
635
636 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
637 let plans = vec![rename_plan(a, "alpha-v2", "rename-a")];
638
639 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
640 assert!(
641 matches!(outcome, AtomicRunOutcome::Committed { .. }),
642 "the real commit-pass closure (SAVEPOINT + guarded DML only) must \
643 resolve on block_on_sync's first poll and commit: {outcome:?}"
644 );
645
646 let writer = pool.try_writer().expect("writer");
647 let name: String = writer
648 .conn()
649 .query_row(
650 "SELECT name FROM entities WHERE id = ?1",
651 rusqlite::params![a.to_string()],
652 |row| row.get(0),
653 )
654 .expect("query renamed entity");
655 assert_eq!(name, "alpha-v2");
656 }
657
658 #[tokio::test]
659 async fn hand_built_suspending_closure_fails_loudly_through_the_same_seam() {
660 let pool = scratch_pool("suspend_trap_misuse");
670 seed_schema(&pool);
671
672 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
673 let suspending_op: AtomicUnitOp = Box::new(|_writer| {
674 Box::pin(async move {
675 tokio::task::yield_now().await;
676 Ok(Box::new(()) as Box<dyn Any + Send>)
677 })
678 });
679
680 let result: StorageResultAlias<Box<dyn Any + Send>> =
681 khive_storage::SqlAccess::atomic_unit(&bridge, suspending_op).await;
682 assert!(
683 result.is_err(),
684 "a closure that suspends on first poll must fail loudly through \
685 `atomic_unit`, never silently succeed or wedge; got {result:?}"
686 );
687 }
688
689 #[tokio::test]
694 async fn concurrent_mutation_between_prepare_and_apply_trips_guard_and_rolls_back() {
695 let pool = scratch_pool("staleness_tripped");
696 seed_schema(&pool);
697 let a = Uuid::new_v4();
698 insert_entity(&pool, a, "alpha");
699
700 let plan = rename_plan(a, "alpha-v2", "rename-a");
702
703 {
706 let writer = pool.try_writer().expect("writer");
707 writer
708 .conn()
709 .execute(
710 "DELETE FROM entities WHERE id = ?1",
711 rusqlite::params![a.to_string()],
712 )
713 .expect("concurrent delete");
714 }
715
716 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
717 let outcome = run_atomic_unit(&bridge, vec![plan])
718 .await
719 .expect("seam call ok");
720 match outcome {
721 AtomicRunOutcome::RolledBack {
722 failed_op_index,
723 failure,
724 } => {
725 assert_eq!(failed_op_index, 0);
726 assert!(matches!(
727 failure,
728 AtomicOpFailure::GuardFailed { observed: 0, .. }
729 ));
730 }
731 other => panic!("expected RolledBack from the stale guard, got {other:?}"),
732 }
733 }
734
735 #[tokio::test]
736 async fn no_mutation_twin_commits() {
737 let pool = scratch_pool("staleness_untripped");
738 seed_schema(&pool);
739 let a = Uuid::new_v4();
740 insert_entity(&pool, a, "alpha");
741
742 let plan = rename_plan(a, "alpha-v2", "rename-a");
743 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
744 let outcome = run_atomic_unit(&bridge, vec![plan])
745 .await
746 .expect("seam call ok");
747 assert!(matches!(outcome, AtomicRunOutcome::Committed { .. }));
748 assert!(entity_exists(&pool, a));
749 }
750
751 #[tokio::test]
756 async fn dangling_edge_from_delete_then_link_rolls_back_whole_unit() {
757 let pool = scratch_pool("dangling_edge");
758 seed_schema(&pool);
759 let x = Uuid::new_v4();
760 let a = Uuid::new_v4();
761 insert_entity(&pool, x, "x");
762 insert_entity(&pool, a, "a");
763 let before = entities_snapshot(&pool);
764
765 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
766 let edge_id = Uuid::new_v4();
767 let plans = vec![delete_plan(x, "delete-x"), link_plan(edge_id, a, x)];
768
769 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
770 match outcome {
771 AtomicRunOutcome::RolledBack {
772 failed_op_index,
773 failure,
774 } => {
775 assert_eq!(
776 failed_op_index, 1,
777 "delete(x) succeeds; link(a, x) is the op whose \
778 endpoint-existence guard fails once x is gone"
779 );
780 assert!(matches!(
781 failure,
782 AtomicOpFailure::GuardFailed { observed: 0, .. }
783 ));
784 }
785 other => panic!("expected RolledBack, got {other:?}"),
786 }
787
788 assert_eq!(
789 edge_count(&pool, a, x),
790 0,
791 "no dangling edge may be committed"
792 );
793 assert_eq!(
794 entities_snapshot(&pool),
795 before,
796 "x must still exist — the whole unit rolled back, including the delete"
797 );
798 }
799
800 #[tokio::test]
805 async fn merge_rewire_sees_earlier_in_file_edge_write() {
806 let pool = scratch_pool("merge_rewire");
807 seed_schema(&pool);
808 let z = Uuid::new_v4();
809 let from = Uuid::new_v4();
810 let into = Uuid::new_v4();
811 insert_entity(&pool, z, "z");
812 insert_entity(&pool, from, "from-entity");
813 insert_entity(&pool, into, "into-entity");
814
815 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
816 let edge_id = Uuid::new_v4();
817 let plans = vec![link_plan(edge_id, from, z), merge_plan(into, from)];
822
823 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
824 assert!(
825 matches!(outcome, AtomicRunOutcome::Committed { .. }),
826 "expected the unit to commit: {outcome:?}"
827 );
828
829 assert_eq!(
830 edge_count(&pool, from, z),
831 0,
832 "no live edge may remain sourced from the merged-away entity"
833 );
834 assert_eq!(
835 edge_count(&pool, into, z),
836 1,
837 "the edge must be rewired onto `into`"
838 );
839 assert!(
840 !entity_exists(&pool, from),
841 "the `from` entity must be tombstoned (soft-deleted)"
842 );
843 }
844
845 #[tokio::test]
850 async fn zero_row_apply_fails_the_whole_unit() {
851 let pool = scratch_pool("zero_row");
852 seed_schema(&pool);
853 let x = Uuid::new_v4();
854 insert_entity(&pool, x, "x");
855 let before = entities_snapshot(&pool);
856
857 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
858 let plans = vec![
861 delete_plan(x, "delete-x"),
862 rename_plan(x, "x-v2", "update-x"),
863 ];
864
865 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
866 match outcome {
867 AtomicRunOutcome::RolledBack {
868 failed_op_index,
869 failure,
870 } => {
871 assert_eq!(failed_op_index, 1);
872 assert_eq!(
873 failure,
874 AtomicOpFailure::GuardFailed {
875 statement_label: Some("update-x".to_string()),
876 expected: AffectedRowGuard::exactly(1),
877 observed: 0,
878 }
879 );
880 }
881 other => panic!("expected RolledBack, got {other:?}"),
882 }
883
884 assert_eq!(
885 entities_snapshot(&pool),
886 before,
887 "x must be restored — the whole unit rolled back"
888 );
889 }
890
891 #[tokio::test]
912 async fn atomic_unit_and_concurrent_normal_write_share_the_same_writer_queue() {
913 let pool = scratch_pool("daemon_coexistence");
914 seed_schema(&pool);
915 let a = Uuid::new_v4();
916 let b = Uuid::new_v4();
917 insert_entity(&pool, a, "alpha");
918
919 let writer_task = pool
920 .writer_task_handle()
921 .expect("writer task lookup")
922 .expect("writer task must be spawned with the flag on for a file-backed pool");
923
924 let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
925 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
926 let occupier = {
927 let writer_task = writer_task.clone();
928 tokio::spawn(async move {
929 writer_task
930 .send(move |_conn| {
931 let _ = started_tx.send(());
932 let _ = release_rx.blocking_recv();
933 Ok::<(), StorageError>(())
934 })
935 .await
936 })
937 };
938
939 started_rx
940 .await
941 .expect("occupier must signal it has started running inside the writer task");
942 assert_eq!(
943 writer_task.queue_depth(),
944 0,
945 "channel must start empty once the occupier has been dequeued and is running"
946 );
947
948 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
950 let atomic_plans = vec![rename_plan(a, "alpha-atomic", "rename-a-atomic")];
951 let atomic_task = tokio::spawn(async move { run_atomic_unit(&bridge, atomic_plans).await });
952
953 let normal_write_task = {
962 let writer_task = writer_task.clone();
963 let b_str = b.to_string();
964 tokio::spawn(async move {
965 writer_task
966 .send(move |conn| {
967 conn.execute(
968 "INSERT INTO entities \
969 (id, namespace, kind, name, created_at, updated_at) \
970 VALUES (?1, 'local', 'concept', ?2, 0, 0)",
971 rusqlite::params![b_str, "bravo"],
972 )
973 .map_err(|e| StorageError::Internal(e.to_string()))
974 })
975 .await
976 })
977 };
978
979 let mut saw_both_enqueued = false;
984 for _ in 0..200 {
985 if writer_task.queue_depth() >= 2 {
986 saw_both_enqueued = true;
987 break;
988 }
989 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
990 }
991 assert!(
992 saw_both_enqueued,
993 "expected BOTH the atomic unit's atomic_unit() call and the \
994 concurrent ordinary write to appear in the writer task's queue \
995 while the occupier held the single drain slot (queue_depth \
996 should have reached 2) — observed {}; run_atomic_unit is not \
997 sharing the same queue as an ordinary write",
998 writer_task.queue_depth()
999 );
1000
1001 release_tx.send(()).expect("release occupier");
1002 occupier
1003 .await
1004 .expect("occupier task join")
1005 .expect("occupier op ok");
1006
1007 let atomic_outcome = atomic_task
1008 .await
1009 .expect("atomic task join")
1010 .expect("seam call ok");
1011 assert!(
1012 matches!(atomic_outcome, AtomicRunOutcome::Committed { .. }),
1013 "atomic unit must commit once the occupier releases: {atomic_outcome:?}"
1014 );
1015 normal_write_task
1016 .await
1017 .expect("normal write task join")
1018 .expect("normal write op ok");
1019
1020 assert!(
1021 entity_exists(&pool, a),
1022 "a must exist (renamed) after commit"
1023 );
1024 {
1025 let writer = pool.try_writer().expect("writer");
1032 let renamed: String = writer
1033 .conn()
1034 .query_row(
1035 "SELECT name FROM entities WHERE id = ?1",
1036 rusqlite::params![a.to_string()],
1037 |row| row.get(0),
1038 )
1039 .expect("query renamed entity");
1040 assert_eq!(renamed, "alpha-atomic");
1041 }
1042 assert!(
1043 entity_exists(&pool, b),
1044 "the concurrent ordinary write must also have landed"
1045 );
1046 }
1047
1048 #[tokio::test]
1049 async fn post_commit_effects_are_collected_in_op_order_and_only_for_update() {
1050 let pool = scratch_pool("post_commit_collection");
1051 seed_schema(&pool);
1052 let a = Uuid::new_v4();
1053 let b = Uuid::new_v4();
1054 insert_entity(&pool, a, "alpha");
1055 insert_entity(&pool, b, "bravo");
1056
1057 let bridge = SqlBridge::new(StdArc::clone(&pool), true);
1058 let mut update_a = match rename_plan(a, "alpha-v2", "rename-a") {
1059 AtomicOpPlan::Update(p) => p,
1060 _ => unreachable!(),
1061 };
1062 update_a.post_commit = PostCommitEffect::ReindexEntity { entity_id: a };
1063 let plans = vec![
1064 AtomicOpPlan::Update(update_a),
1065 delete_plan(b, "delete-b"), ];
1067
1068 let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
1069 match outcome {
1070 AtomicRunOutcome::Committed { post_commit } => {
1071 assert_eq!(
1072 post_commit,
1073 vec![PostCommitEffect::ReindexEntity { entity_id: a }]
1074 );
1075 }
1076 other => panic!("expected Committed, got {other:?}"),
1077 }
1078 }
1079}