use std::any::Any;
use std::sync::{Arc, Mutex};
use khive_storage::{AtomicUnitOp, SqlAccess, SqlStatement, SqlWriter, StorageError};
use crate::atomic_plan::{
AddEntityPlan, AddNotePlan, AffectedRowGuard, DeletePlan, GovernancePlan, GtdCompletePlan,
GtdTransitionPlan, LinkPlan, MergePlan, PlanStatement, PostCommitEffect, UpdatePlan,
};
#[derive(Debug, Clone)]
pub enum AtomicOpPlan {
AddEntity(AddEntityPlan),
AddNote(AddNotePlan),
Update(UpdatePlan),
Delete(DeletePlan),
Link(LinkPlan),
Merge(MergePlan),
GtdTransition(GtdTransitionPlan),
GtdComplete(GtdCompletePlan),
Governance(GovernancePlan),
}
impl AtomicOpPlan {
fn plan_statements(&self) -> Vec<PlanStatement> {
match self {
AtomicOpPlan::AddEntity(p) => p.statements.clone(),
AtomicOpPlan::AddNote(p) => p.statements.clone(),
AtomicOpPlan::Update(p) => p.statements.clone(),
AtomicOpPlan::Delete(p) => p.statements.clone(),
AtomicOpPlan::Link(p) => vec![p.statement.clone()],
AtomicOpPlan::Merge(p) => {
let mut statements: Vec<PlanStatement> = p
.rewires
.iter()
.map(|rewire| PlanStatement {
statement: rewire.statement.clone(),
guard: None,
})
.collect();
statements.extend(p.lifecycle.clone());
statements
}
AtomicOpPlan::GtdTransition(p) => p.statements.clone(),
AtomicOpPlan::GtdComplete(p) => p.statements.clone(),
AtomicOpPlan::Governance(p) => p.statements.clone(),
}
}
fn post_commit_effect(&self) -> Option<PostCommitEffect> {
match self {
AtomicOpPlan::AddEntity(p) if p.post_commit != PostCommitEffect::None => {
Some(p.post_commit.clone())
}
AtomicOpPlan::AddNote(p) if p.post_commit != PostCommitEffect::None => {
Some(p.post_commit.clone())
}
AtomicOpPlan::Update(p) if p.post_commit != PostCommitEffect::None => {
Some(p.post_commit.clone())
}
AtomicOpPlan::Delete(p) if p.post_commit != PostCommitEffect::None => {
Some(p.post_commit.clone())
}
AtomicOpPlan::GtdTransition(p) if p.post_commit != PostCommitEffect::None => {
Some(p.post_commit.clone())
}
AtomicOpPlan::GtdComplete(p) if p.post_commit != PostCommitEffect::None => {
Some(p.post_commit.clone())
}
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AtomicOpFailure {
GuardFailed {
statement_label: Option<String>,
expected: AffectedRowGuard,
observed: u64,
},
SqlError {
statement_label: Option<String>,
message: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AtomicRunOutcome {
Committed { post_commit: Vec<PostCommitEffect> },
RolledBack {
failed_op_index: usize,
failure: AtomicOpFailure,
},
}
#[derive(Debug)]
pub struct AtomicRunnerError(pub StorageError);
impl std::fmt::Display for AtomicRunnerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "atomic_unit seam failure: {}", self.0)
}
}
impl std::error::Error for AtomicRunnerError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.0)
}
}
fn raw_stmt(sql: String) -> SqlStatement {
SqlStatement {
sql,
params: vec![],
label: None,
}
}
async fn begin_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
writer
.execute(raw_stmt(format!("SAVEPOINT {name}")))
.await
.map(|_| ())
}
async fn release_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
writer
.execute(raw_stmt(format!("RELEASE {name}")))
.await
.map(|_| ())
}
async fn rollback_to_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
writer
.execute(raw_stmt(format!("ROLLBACK TO {name}")))
.await
.map(|_| ())
}
async fn apply_plan(
writer: &mut dyn SqlWriter,
plan: &AtomicOpPlan,
) -> Result<(), AtomicOpFailure> {
for stmt in plan.plan_statements() {
let label = stmt.statement.label.clone();
let affected =
writer
.execute(stmt.statement)
.await
.map_err(|e| AtomicOpFailure::SqlError {
statement_label: label.clone(),
message: e.to_string(),
})?;
if let Some(guard) = stmt.guard {
if !guard.holds_for(affected) {
return Err(AtomicOpFailure::GuardFailed {
statement_label: label,
expected: guard,
observed: affected,
});
}
}
}
Ok(())
}
pub async fn run_atomic_unit(
access: &dyn SqlAccess,
plans: Vec<AtomicOpPlan>,
) -> Result<AtomicRunOutcome, AtomicRunnerError> {
let failure_slot: Arc<Mutex<Option<(usize, AtomicOpFailure)>>> = Arc::new(Mutex::new(None));
let failure_slot_for_closure = Arc::clone(&failure_slot);
let op: AtomicUnitOp = Box::new(move |writer| {
Box::pin(async move {
let mut post_commit = Vec::new();
for (op_index, plan) in plans.iter().enumerate() {
let savepoint = format!("adr099_atomic_op_{op_index}");
begin_savepoint(writer, &savepoint).await?;
match apply_plan(writer, plan).await {
Ok(()) => {
release_savepoint(writer, &savepoint).await?;
if let Some(effect) = plan.post_commit_effect() {
post_commit.push(effect);
}
}
Err(failure) => {
let _ = rollback_to_savepoint(writer, &savepoint).await;
let _ = release_savepoint(writer, &savepoint).await;
*failure_slot_for_closure
.lock()
.expect("atomic runner failure slot poisoned") =
Some((op_index, failure));
return Err(StorageError::Internal(format!(
"ADR-099 atomic unit aborted at op {op_index}"
)));
}
}
}
Ok(Box::new(post_commit) as Box<dyn Any + Send>)
})
});
match access.atomic_unit(op).await {
Ok(boxed) => {
let post_commit = *boxed.downcast::<Vec<PostCommitEffect>>().expect(
"run_atomic_unit's own closure always returns Box<Vec<PostCommitEffect>> on Ok",
);
Ok(AtomicRunOutcome::Committed { post_commit })
}
Err(storage_err) => {
let recorded = failure_slot
.lock()
.expect("atomic runner failure slot poisoned")
.take();
match recorded {
Some((failed_op_index, failure)) => Ok(AtomicRunOutcome::RolledBack {
failed_op_index,
failure,
}),
None => Err(AtomicRunnerError(storage_err)),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc as StdArc;
use khive_db::{ConnectionPool, PoolConfig, SqlBridge};
use khive_storage::types::{SqlValue, StorageResult as StorageResultAlias};
use uuid::Uuid;
fn scratch_pool(name: &str) -> StdArc<ConnectionPool> {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(format!("{name}.db"));
let pool = StdArc::new(
ConnectionPool::new(PoolConfig {
path: Some(path),
write_queue_enabled: true,
..PoolConfig::default()
})
.expect("pool open"),
);
std::mem::forget(dir);
pool
}
fn seed_schema(pool: &ConnectionPool) {
let writer = pool.try_writer().expect("writer");
writer
.conn()
.execute_batch(
"CREATE TABLE entities (
id TEXT PRIMARY KEY,
namespace TEXT NOT NULL,
kind TEXT NOT NULL,
entity_type TEXT,
name TEXT NOT NULL,
description TEXT,
properties TEXT,
tags TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
merged_into TEXT,
merge_event_id TEXT
);
CREATE TABLE graph_edges (
namespace TEXT NOT NULL,
id TEXT NOT NULL,
source_id TEXT NOT NULL,
target_id TEXT NOT NULL,
relation TEXT NOT NULL,
weight REAL NOT NULL DEFAULT 1.0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
metadata TEXT,
target_backend TEXT,
PRIMARY KEY (namespace, id)
);",
)
.expect("seed schema");
}
fn insert_entity(pool: &ConnectionPool, id: Uuid, name: &str) {
let writer = pool.try_writer().expect("writer");
writer
.conn()
.execute(
"INSERT INTO entities \
(id, namespace, kind, name, created_at, updated_at) \
VALUES (?1, 'local', 'concept', ?2, 0, 0)",
rusqlite::params![id.to_string(), name],
)
.expect("insert entity");
}
fn entity_exists(pool: &ConnectionPool, id: Uuid) -> bool {
let writer = pool.try_writer().expect("writer");
let count: i64 = writer
.conn()
.query_row(
"SELECT COUNT(*) FROM entities WHERE id = ?1 AND deleted_at IS NULL",
rusqlite::params![id.to_string()],
|row| row.get(0),
)
.expect("query entity");
count > 0
}
fn edge_count(pool: &ConnectionPool, source: Uuid, target: Uuid) -> i64 {
let writer = pool.try_writer().expect("writer");
writer
.conn()
.query_row(
"SELECT COUNT(*) FROM graph_edges \
WHERE source_id = ?1 AND target_id = ?2 AND deleted_at IS NULL",
rusqlite::params![source.to_string(), target.to_string()],
|row| row.get(0),
)
.expect("query edge")
}
fn entities_snapshot(pool: &ConnectionPool) -> Vec<String> {
let writer = pool.try_writer().expect("writer");
let mut stmt = writer
.conn()
.prepare("SELECT id, name, deleted_at FROM entities ORDER BY id")
.expect("prepare snapshot");
let rows = stmt
.query_map([], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let deleted_at: Option<i64> = row.get(2)?;
Ok(format!("{id}:{name}:{deleted_at:?}"))
})
.expect("query snapshot");
rows.collect::<Result<Vec<_>, _>>()
.expect("collect snapshot")
}
fn delete_plan(id: Uuid, label: &str) -> AtomicOpPlan {
AtomicOpPlan::Delete(DeletePlan {
target_id: id,
statements: vec![PlanStatement {
statement: SqlStatement {
sql: "DELETE FROM entities WHERE id = ?1 AND deleted_at IS NULL".to_string(),
params: vec![SqlValue::Text(id.to_string())],
label: Some(label.to_string()),
},
guard: Some(AffectedRowGuard::exactly(1)),
}],
post_commit: PostCommitEffect::None,
})
}
fn rename_plan(id: Uuid, new_name: &str, label: &str) -> AtomicOpPlan {
AtomicOpPlan::Update(UpdatePlan {
target_id: id,
statements: vec![PlanStatement {
statement: SqlStatement {
sql: "UPDATE entities SET name = ?1, updated_at = 1 \
WHERE id = ?2 AND deleted_at IS NULL"
.to_string(),
params: vec![
SqlValue::Text(new_name.to_string()),
SqlValue::Text(id.to_string()),
],
label: Some(label.to_string()),
},
guard: Some(AffectedRowGuard::exactly(1)),
}],
post_commit: PostCommitEffect::None,
edge_natural_key: None,
})
}
fn link_plan(edge_id: Uuid, source: Uuid, target: Uuid) -> AtomicOpPlan {
AtomicOpPlan::Link(LinkPlan {
source_id: source,
target_id: target,
statement: PlanStatement {
statement: SqlStatement {
sql: "INSERT INTO graph_edges \
(namespace, id, source_id, target_id, relation, created_at, updated_at) \
SELECT 'local', ?1, ?2, ?3, 'annotates', 0, 0 \
WHERE EXISTS (SELECT 1 FROM entities WHERE id = ?2 AND deleted_at IS NULL) \
AND EXISTS (SELECT 1 FROM entities WHERE id = ?3 AND deleted_at IS NULL)"
.to_string(),
params: vec![
SqlValue::Text(edge_id.to_string()),
SqlValue::Text(source.to_string()),
SqlValue::Text(target.to_string()),
],
label: Some("insert-edge-where-exists".to_string()),
},
guard: Some(AffectedRowGuard::exactly(1)),
},
})
}
fn merge_plan(into_id: Uuid, from_id: Uuid) -> AtomicOpPlan {
AtomicOpPlan::Merge(MergePlan {
into_id,
from_id,
rewires: vec![crate::atomic_plan::PlanPredicate {
description: "source_id = :from".to_string(),
statement: SqlStatement {
sql: "UPDATE graph_edges SET source_id = ?1, updated_at = 1 \
WHERE source_id = ?2"
.to_string(),
params: vec![
SqlValue::Text(into_id.to_string()),
SqlValue::Text(from_id.to_string()),
],
label: Some("merge-rewire".to_string()),
},
}],
lifecycle: vec![PlanStatement {
statement: SqlStatement {
sql: "UPDATE entities SET deleted_at = 1, merged_into = ?1 \
WHERE id = ?2 AND deleted_at IS NULL"
.to_string(),
params: vec![
SqlValue::Text(into_id.to_string()),
SqlValue::Text(from_id.to_string()),
],
label: Some("tombstone-from-entity".to_string()),
},
guard: Some(AffectedRowGuard::exactly(1)),
}],
})
}
#[tokio::test]
async fn rollback_end_to_end_leaves_zero_partial_state() {
let pool = scratch_pool("rollback_end_to_end");
seed_schema(&pool);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
insert_entity(&pool, a, "alpha");
insert_entity(&pool, b, "bravo");
let before = entities_snapshot(&pool);
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let plans = vec![
rename_plan(a, "alpha-renamed", "rename-a"),
delete_plan(Uuid::new_v4(), "delete-nonexistent"),
rename_plan(b, "bravo-renamed", "rename-b"),
];
let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
match outcome {
AtomicRunOutcome::RolledBack {
failed_op_index,
failure,
} => {
assert_eq!(failed_op_index, 1);
assert!(matches!(failure, AtomicOpFailure::GuardFailed { .. }));
}
other => panic!("expected RolledBack, got {other:?}"),
}
let after = entities_snapshot(&pool);
assert_eq!(
before, after,
"a mid-unit failure must leave the database byte-for-byte unchanged"
);
}
#[tokio::test]
async fn commit_pass_resolves_on_first_poll_and_commits() {
let pool = scratch_pool("suspend_trap_happy_path");
seed_schema(&pool);
let a = Uuid::new_v4();
insert_entity(&pool, a, "alpha");
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let plans = vec![rename_plan(a, "alpha-v2", "rename-a")];
let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
assert!(
matches!(outcome, AtomicRunOutcome::Committed { .. }),
"the real commit-pass closure (SAVEPOINT + guarded DML only) must \
resolve on block_on_sync's first poll and commit: {outcome:?}"
);
let writer = pool.try_writer().expect("writer");
let name: String = writer
.conn()
.query_row(
"SELECT name FROM entities WHERE id = ?1",
rusqlite::params![a.to_string()],
|row| row.get(0),
)
.expect("query renamed entity");
assert_eq!(name, "alpha-v2");
}
#[tokio::test]
async fn hand_built_suspending_closure_fails_loudly_through_the_same_seam() {
let pool = scratch_pool("suspend_trap_misuse");
seed_schema(&pool);
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let suspending_op: AtomicUnitOp = Box::new(|_writer| {
Box::pin(async move {
tokio::task::yield_now().await;
Ok(Box::new(()) as Box<dyn Any + Send>)
})
});
let result: StorageResultAlias<Box<dyn Any + Send>> =
khive_storage::SqlAccess::atomic_unit(&bridge, suspending_op).await;
assert!(
result.is_err(),
"a closure that suspends on first poll must fail loudly through \
`atomic_unit`, never silently succeed or wedge; got {result:?}"
);
}
#[tokio::test]
async fn concurrent_mutation_between_prepare_and_apply_trips_guard_and_rolls_back() {
let pool = scratch_pool("staleness_tripped");
seed_schema(&pool);
let a = Uuid::new_v4();
insert_entity(&pool, a, "alpha");
let plan = rename_plan(a, "alpha-v2", "rename-a");
{
let writer = pool.try_writer().expect("writer");
writer
.conn()
.execute(
"DELETE FROM entities WHERE id = ?1",
rusqlite::params![a.to_string()],
)
.expect("concurrent delete");
}
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let outcome = run_atomic_unit(&bridge, vec![plan])
.await
.expect("seam call ok");
match outcome {
AtomicRunOutcome::RolledBack {
failed_op_index,
failure,
} => {
assert_eq!(failed_op_index, 0);
assert!(matches!(
failure,
AtomicOpFailure::GuardFailed { observed: 0, .. }
));
}
other => panic!("expected RolledBack from the stale guard, got {other:?}"),
}
}
#[tokio::test]
async fn no_mutation_twin_commits() {
let pool = scratch_pool("staleness_untripped");
seed_schema(&pool);
let a = Uuid::new_v4();
insert_entity(&pool, a, "alpha");
let plan = rename_plan(a, "alpha-v2", "rename-a");
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let outcome = run_atomic_unit(&bridge, vec![plan])
.await
.expect("seam call ok");
assert!(matches!(outcome, AtomicRunOutcome::Committed { .. }));
assert!(entity_exists(&pool, a));
}
#[tokio::test]
async fn dangling_edge_from_delete_then_link_rolls_back_whole_unit() {
let pool = scratch_pool("dangling_edge");
seed_schema(&pool);
let x = Uuid::new_v4();
let a = Uuid::new_v4();
insert_entity(&pool, x, "x");
insert_entity(&pool, a, "a");
let before = entities_snapshot(&pool);
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let edge_id = Uuid::new_v4();
let plans = vec![delete_plan(x, "delete-x"), link_plan(edge_id, a, x)];
let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
match outcome {
AtomicRunOutcome::RolledBack {
failed_op_index,
failure,
} => {
assert_eq!(
failed_op_index, 1,
"delete(x) succeeds; link(a, x) is the op whose \
endpoint-existence guard fails once x is gone"
);
assert!(matches!(
failure,
AtomicOpFailure::GuardFailed { observed: 0, .. }
));
}
other => panic!("expected RolledBack, got {other:?}"),
}
assert_eq!(
edge_count(&pool, a, x),
0,
"no dangling edge may be committed"
);
assert_eq!(
entities_snapshot(&pool),
before,
"x must still exist — the whole unit rolled back, including the delete"
);
}
#[tokio::test]
async fn merge_rewire_sees_earlier_in_file_edge_write() {
let pool = scratch_pool("merge_rewire");
seed_schema(&pool);
let z = Uuid::new_v4();
let from = Uuid::new_v4();
let into = Uuid::new_v4();
insert_entity(&pool, z, "z");
insert_entity(&pool, from, "from-entity");
insert_entity(&pool, into, "into-entity");
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let edge_id = Uuid::new_v4();
let plans = vec![link_plan(edge_id, from, z), merge_plan(into, from)];
let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
assert!(
matches!(outcome, AtomicRunOutcome::Committed { .. }),
"expected the unit to commit: {outcome:?}"
);
assert_eq!(
edge_count(&pool, from, z),
0,
"no live edge may remain sourced from the merged-away entity"
);
assert_eq!(
edge_count(&pool, into, z),
1,
"the edge must be rewired onto `into`"
);
assert!(
!entity_exists(&pool, from),
"the `from` entity must be tombstoned (soft-deleted)"
);
}
#[tokio::test]
async fn zero_row_apply_fails_the_whole_unit() {
let pool = scratch_pool("zero_row");
seed_schema(&pool);
let x = Uuid::new_v4();
insert_entity(&pool, x, "x");
let before = entities_snapshot(&pool);
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let plans = vec![
delete_plan(x, "delete-x"),
rename_plan(x, "x-v2", "update-x"),
];
let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
match outcome {
AtomicRunOutcome::RolledBack {
failed_op_index,
failure,
} => {
assert_eq!(failed_op_index, 1);
assert_eq!(
failure,
AtomicOpFailure::GuardFailed {
statement_label: Some("update-x".to_string()),
expected: AffectedRowGuard::exactly(1),
observed: 0,
}
);
}
other => panic!("expected RolledBack, got {other:?}"),
}
assert_eq!(
entities_snapshot(&pool),
before,
"x must be restored — the whole unit rolled back"
);
}
#[tokio::test]
async fn atomic_unit_and_concurrent_normal_write_share_the_same_writer_queue() {
let pool = scratch_pool("daemon_coexistence");
seed_schema(&pool);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
insert_entity(&pool, a, "alpha");
let writer_task = pool
.writer_task_handle()
.expect("writer task lookup")
.expect("writer task must be spawned with the flag on for a file-backed pool");
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
let occupier = {
let writer_task = writer_task.clone();
tokio::spawn(async move {
writer_task
.send(move |_conn| {
let _ = started_tx.send(());
let _ = release_rx.blocking_recv();
Ok::<(), StorageError>(())
})
.await
})
};
started_rx
.await
.expect("occupier must signal it has started running inside the writer task");
assert_eq!(
writer_task.queue_depth(),
0,
"channel must start empty once the occupier has been dequeued and is running"
);
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let atomic_plans = vec![rename_plan(a, "alpha-atomic", "rename-a-atomic")];
let atomic_task = tokio::spawn(async move { run_atomic_unit(&bridge, atomic_plans).await });
let normal_write_task = {
let writer_task = writer_task.clone();
let b_str = b.to_string();
tokio::spawn(async move {
writer_task
.send(move |conn| {
conn.execute(
"INSERT INTO entities \
(id, namespace, kind, name, created_at, updated_at) \
VALUES (?1, 'local', 'concept', ?2, 0, 0)",
rusqlite::params![b_str, "bravo"],
)
.map_err(|e| StorageError::Internal(e.to_string()))
})
.await
})
};
let mut saw_both_enqueued = false;
for _ in 0..200 {
if writer_task.queue_depth() >= 2 {
saw_both_enqueued = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
assert!(
saw_both_enqueued,
"expected BOTH the atomic unit's atomic_unit() call and the \
concurrent ordinary write to appear in the writer task's queue \
while the occupier held the single drain slot (queue_depth \
should have reached 2) — observed {}; run_atomic_unit is not \
sharing the same queue as an ordinary write",
writer_task.queue_depth()
);
release_tx.send(()).expect("release occupier");
occupier
.await
.expect("occupier task join")
.expect("occupier op ok");
let atomic_outcome = atomic_task
.await
.expect("atomic task join")
.expect("seam call ok");
assert!(
matches!(atomic_outcome, AtomicRunOutcome::Committed { .. }),
"atomic unit must commit once the occupier releases: {atomic_outcome:?}"
);
normal_write_task
.await
.expect("normal write task join")
.expect("normal write op ok");
assert!(
entity_exists(&pool, a),
"a must exist (renamed) after commit"
);
{
let writer = pool.try_writer().expect("writer");
let renamed: String = writer
.conn()
.query_row(
"SELECT name FROM entities WHERE id = ?1",
rusqlite::params![a.to_string()],
|row| row.get(0),
)
.expect("query renamed entity");
assert_eq!(renamed, "alpha-atomic");
}
assert!(
entity_exists(&pool, b),
"the concurrent ordinary write must also have landed"
);
}
#[tokio::test]
async fn post_commit_effects_are_collected_in_op_order_and_only_for_update() {
let pool = scratch_pool("post_commit_collection");
seed_schema(&pool);
let a = Uuid::new_v4();
let b = Uuid::new_v4();
insert_entity(&pool, a, "alpha");
insert_entity(&pool, b, "bravo");
let bridge = SqlBridge::new(StdArc::clone(&pool), true);
let mut update_a = match rename_plan(a, "alpha-v2", "rename-a") {
AtomicOpPlan::Update(p) => p,
_ => unreachable!(),
};
update_a.post_commit = PostCommitEffect::ReindexEntity { entity_id: a };
let plans = vec![
AtomicOpPlan::Update(update_a),
delete_plan(b, "delete-b"), ];
let outcome = run_atomic_unit(&bridge, plans).await.expect("seam call ok");
match outcome {
AtomicRunOutcome::Committed { post_commit } => {
assert_eq!(
post_commit,
vec![PostCommitEffect::ReindexEntity { entity_id: a }]
);
}
other => panic!("expected Committed, got {other:?}"),
}
}
}