1use std::any::Any;
29use std::sync::{Arc, Mutex};
30
31use khive_storage::{AtomicUnitOp, BoxFuture, 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 Canonical(Box<crate::canonical_apply::CanonicalMutationPlan>),
48 AddEntity(AddEntityPlan),
49 FinalizeEntity(Box<crate::EntityFinalizationPlan>),
50 AddNote(Box<AddNotePlan>),
51 Update(Box<UpdatePlan>),
52 Delete(DeletePlan),
53 Link(LinkPlan),
54 Merge(MergePlan),
55 GtdTransition(GtdTransitionPlan),
56 GtdComplete(GtdCompletePlan),
57 Governance(GovernancePlan),
58}
59
60impl AtomicOpPlan {
61 fn plan_statements(&self) -> Vec<(PlanStatement, bool)> {
71 let statements = match self {
72 AtomicOpPlan::Canonical(_) => Vec::new(),
73 AtomicOpPlan::AddEntity(p) => p.statements.clone(),
74 AtomicOpPlan::FinalizeEntity(_) => Vec::new(),
76 AtomicOpPlan::AddNote(p) => p.statements.clone(),
77 AtomicOpPlan::Update(p) => p.statements.clone(),
78 AtomicOpPlan::Delete(p) => p.statements.clone(),
79 AtomicOpPlan::Link(p) => p.statements.clone(),
80 AtomicOpPlan::Merge(p) => {
81 let mut statements: Vec<PlanStatement> = p
82 .rewires
83 .iter()
84 .map(|rewire| PlanStatement {
85 statement: rewire.statement.clone(),
86 guard: None,
87 })
88 .collect();
89 statements.extend(p.lifecycle.clone());
90 statements
91 }
92 AtomicOpPlan::GtdTransition(p) => p.statements.clone(),
93 AtomicOpPlan::GtdComplete(p) => p.statements.clone(),
94 AtomicOpPlan::Governance(p) => p.statements.clone(),
95 };
96 let mut statements: Vec<_> = statements
97 .into_iter()
98 .map(|statement| (statement, false))
99 .collect();
100 if let AtomicOpPlan::Update(plan) = self {
101 statements.extend(plan.graph_effects.iter().map(|effect| match effect {
102 crate::atomic_plan::NoteUpdateStatement::Write(statement) => {
103 (statement.clone(), false)
104 }
105 crate::atomic_plan::NoteUpdateStatement::Assert(statement) => {
106 (statement.clone(), true)
107 }
108 }));
109 }
110 statements
111 }
112
113 fn post_commit_effect(&self) -> Option<PostCommitEffect> {
127 match self {
128 AtomicOpPlan::AddEntity(p) if p.post_commit != PostCommitEffect::None => {
129 Some(p.post_commit.clone())
130 }
131 AtomicOpPlan::AddNote(p) if p.post_commit != PostCommitEffect::None => {
132 Some(p.post_commit.clone())
133 }
134 AtomicOpPlan::Update(p) if p.post_commit != PostCommitEffect::None => {
135 Some(p.post_commit.clone())
136 }
137 AtomicOpPlan::Delete(p) if p.post_commit != PostCommitEffect::None => {
138 Some(p.post_commit.clone())
139 }
140 AtomicOpPlan::GtdTransition(p) if p.post_commit != PostCommitEffect::None => {
141 Some(p.post_commit.clone())
142 }
143 AtomicOpPlan::GtdComplete(p) if p.post_commit != PostCommitEffect::None => {
144 Some(p.post_commit.clone())
145 }
146 _ => None,
147 }
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum AtomicOpFailure {
155 PreimageMismatch {
157 target_id: uuid::Uuid,
158 field_path: String,
159 },
160 OwnerSnapshotConflict {
162 target_id: uuid::Uuid,
163 kind: String,
164 },
165 NoteConflict(crate::note_write::NoteWriteConflict),
166 EntityConflict(crate::entity_write::EntityVersionConflict),
167 GuardFailed {
173 statement_label: Option<String>,
174 expected: AffectedRowGuard,
175 observed: u64,
176 },
177 SqlError {
181 statement_label: Option<String>,
182 message: String,
183 },
184}
185
186#[derive(Debug, PartialEq, Eq)]
208pub struct CommittedPostCommitEffects {
209 effects: Vec<PostCommitEffect>,
210}
211
212impl CommittedPostCommitEffects {
213 fn new(effects: Vec<PostCommitEffect>) -> Self {
214 Self { effects }
215 }
216
217 pub fn as_slice(&self) -> &[PostCommitEffect] {
220 &self.effects
221 }
222
223 pub(crate) fn into_effects(self) -> Vec<PostCommitEffect> {
224 self.effects
225 }
226}
227
228#[derive(Debug, PartialEq, Eq)]
232pub enum AtomicRunOutcome {
233 Committed {
241 post_commit: CommittedPostCommitEffects,
242 },
243 RolledBack {
251 failed_op_index: usize,
252 failure: AtomicOpFailure,
253 },
254}
255
256#[derive(Debug)]
263pub struct AtomicRunnerError(pub StorageError);
264
265impl std::fmt::Display for AtomicRunnerError {
266 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
267 write!(f, "atomic_unit seam failure: {}", self.0)
268 }
269}
270
271impl std::error::Error for AtomicRunnerError {
272 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
273 Some(&self.0)
274 }
275}
276
277async fn execute_savepoint(writer: &mut dyn SqlWriter, sql: String) -> Result<(), StorageError> {
278 writer
279 .execute(SqlStatement::new(sql, vec![]))
280 .await
281 .map(|_| ())
282}
283
284async fn begin_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
285 execute_savepoint(writer, format!("SAVEPOINT {name}")).await
286}
287
288async fn release_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
289 execute_savepoint(writer, format!("RELEASE {name}")).await
290}
291
292async fn rollback_to_savepoint(writer: &mut dyn SqlWriter, name: &str) -> Result<(), StorageError> {
293 execute_savepoint(writer, format!("ROLLBACK TO {name}")).await
294}
295
296pub(crate) struct AppliedPlan {
303 pub(crate) effect: Option<PostCommitEffect>,
304 pub(crate) note_version: Option<i64>,
305}
306
307pub(crate) async fn apply_plan(
310 writer: &mut dyn SqlWriter,
311 plan: &AtomicOpPlan,
312 capture_note_versions: bool,
313) -> Result<AppliedPlan, AtomicOpFailure> {
314 apply_plan_preserving_storage(writer, plan, capture_note_versions)
315 .await
316 .map_err(|failure| match failure {
317 PlanExecutionFailure::Operation(failure) => failure,
318 PlanExecutionFailure::Storage {
319 statement_label,
320 source,
321 } => AtomicOpFailure::SqlError {
322 statement_label,
323 message: source.to_string(),
324 },
325 })
326}
327pub(crate) enum PlanExecutionFailure {
328 Operation(AtomicOpFailure),
329 Storage {
330 statement_label: Option<String>,
331 source: StorageError,
332 },
333}
334impl From<AtomicOpFailure> for PlanExecutionFailure {
335 fn from(failure: AtomicOpFailure) -> Self {
336 Self::Operation(failure)
337 }
338}
339
340pub(crate) async fn apply_plan_preserving_storage(
341 writer: &mut dyn SqlWriter,
342 plan: &AtomicOpPlan,
343 capture_note_versions: bool,
344) -> Result<AppliedPlan, PlanExecutionFailure> {
345 if matches!(plan, AtomicOpPlan::Canonical(_)) {
346 return Err(AtomicOpFailure::SqlError {
347 statement_label: Some("canonical-materialize".into()),
348 message: "canonical plan requires the ordered atomic runner".into(),
349 }
350 .into());
351 }
352 if let AtomicOpPlan::FinalizeEntity(plan) = plan {
353 return plan
354 .apply(writer)
355 .await
356 .map(|effect| AppliedPlan {
357 effect,
358 note_version: None,
359 })
360 .map_err(PlanExecutionFailure::Operation);
361 }
362 if let AtomicOpPlan::Update(plan) = plan {
363 if let Some(guard) = &plan.entity_guard {
364 if let Some(conflict) =
365 guard
366 .check(writer)
367 .await
368 .map_err(|error| PlanExecutionFailure::Storage {
369 statement_label: Some("entity-version-precondition".into()),
370 source: error,
371 })?
372 {
373 return Err(AtomicOpFailure::EntityConflict(conflict).into());
374 }
375 }
376 }
377 let note_guard = match plan {
378 AtomicOpPlan::Update(plan) => plan.note_guard.as_ref(),
379 AtomicOpPlan::AddNote(plan) => plan.note_guard.as_ref(),
380 _ => None,
381 };
382 if let Some(guard) = note_guard {
383 if let Some(conflict) =
384 guard
385 .check_fence(writer)
386 .await
387 .map_err(|error| PlanExecutionFailure::Storage {
388 statement_label: Some("note-fence".into()),
389 source: error,
390 })?
391 {
392 return Err(AtomicOpFailure::NoteConflict(conflict).into());
393 }
394 }
395 let mut post_commit = plan.post_commit_effect();
396 if let AtomicOpPlan::Update(plan) = plan {
397 if let Some(inheritance) = &plan.note_embedding_inheritance {
398 if !inheritance
399 .vectors
400 .has_rows(writer)
401 .await
402 .map_err(|error| PlanExecutionFailure::Storage {
403 statement_label: Some("note-embedding-inheritance".into()),
404 source: error,
405 })?
406 {
407 post_commit = Some(PostCommitEffect::NoteChanged {
408 note_id: plan.target_id,
409 kind: inheritance.kind.clone(),
410 });
411 }
412 }
413 }
414 for (index, (stmt, read_assertion)) in plan.plan_statements().into_iter().enumerate() {
415 let label = stmt.statement.label.clone();
416 let result = if read_assertion
419 || matches!(plan, AtomicOpPlan::GtdTransition(p) if p.idempotent_noop)
420 || matches!(plan, AtomicOpPlan::Update(p) if p.idempotent_noop)
421 {
422 writer
423 .query_all(stmt.statement)
424 .await
425 .map(|rows| rows.len() as u64)
426 } else {
427 writer.execute(stmt.statement).await
428 };
429 let affected = result.map_err(|e| PlanExecutionFailure::Storage {
430 statement_label: label.clone(),
431 source: e,
432 })?;
433 if let Some(guard) = stmt.guard {
434 if !guard.holds_for(affected) {
435 if let Some(note_guard) = note_guard.filter(|_| index == 0) {
436 if let Some(conflict) =
437 note_guard.classify_refusal(writer).await.map_err(|error| {
438 PlanExecutionFailure::Storage {
439 statement_label: label.clone(),
440 source: error,
441 }
442 })?
443 {
444 return Err(AtomicOpFailure::NoteConflict(conflict).into());
445 }
446 }
447 return Err(AtomicOpFailure::GuardFailed {
448 statement_label: label,
449 expected: guard,
450 observed: affected,
451 }
452 .into());
453 }
454 }
455 }
456 if let AtomicOpPlan::Update(plan) = plan {
457 if let Some(purge) = &plan.note_vector_purge {
458 purge
459 .apply(writer)
460 .await
461 .map_err(|error| PlanExecutionFailure::Storage {
462 statement_label: Some("note-vector-purge".into()),
463 source: error,
464 })?;
465 }
466 }
467 let note_version = if let AtomicOpPlan::AddNote(plan) = plan {
468 if capture_note_versions {
469 let current = writer
470 .query_scalar(
471 SqlStatement::new(
472 "SELECT version FROM notes WHERE id=?1",
473 vec![khive_storage::SqlValue::Text(plan.note_id.to_string())],
474 )
475 .labelled("note-write-guard"),
476 )
477 .await
478 .map_err(|error| PlanExecutionFailure::Storage {
479 statement_label: Some("note-version-receipt".into()),
480 source: error,
481 })?;
482 let Some(khive_storage::SqlValue::Integer(version)) = current else {
483 return Err(AtomicOpFailure::SqlError {
484 statement_label: Some("note-version-receipt".into()),
485 message: "missing persisted note version".into(),
486 }
487 .into());
488 };
489 Some(version)
490 } else {
491 None
492 }
493 } else {
494 None
495 };
496 Ok(AppliedPlan {
497 effect: post_commit,
498 note_version,
499 })
500}
501
502pub async fn run_atomic_unit(
525 access: &dyn SqlAccess,
526 plans: Vec<AtomicOpPlan>,
527) -> Result<AtomicRunOutcome, AtomicRunnerError> {
528 run_atomic_unit_with_note_versions(access, plans, false)
529 .await
530 .map(|(outcome, _)| outcome)
531}
532
533pub(crate) async fn run_atomic_unit_with_note_versions(
536 access: &dyn SqlAccess,
537 plans: Vec<AtomicOpPlan>,
538 capture_note_versions: bool,
539) -> Result<(AtomicRunOutcome, Vec<i64>), AtomicRunnerError> {
540 let finalizers: Vec<_> = plans
541 .iter()
542 .enumerate()
543 .filter_map(|(index, plan)| match plan {
544 AtomicOpPlan::FinalizeEntity(plan) => Some((index, plan.for_settlement())),
545 _ => None,
546 })
547 .collect();
548 let op: PreparedAtomicOp<Vec<i64>, (usize, AtomicOpFailure)> = Box::new(move |writer| {
549 Box::pin(async move {
550 let mut post_commit = Vec::new();
551 let mut note_versions = Vec::new();
552 for (op_index, plan) in plans.iter().enumerate() {
553 let savepoint = format!("adr099_atomic_op_{op_index}");
554 begin_savepoint(writer, &savepoint).await?;
555 let applied = match plan {
556 AtomicOpPlan::Canonical(canonical) => {
557 match crate::canonical_apply::materialize(writer, canonical).await {
558 Ok(materialized) => {
559 apply_plan(writer, &materialized, capture_note_versions).await
560 }
561 Err(failure) => Err(failure),
562 }
563 }
564 _ => apply_plan(writer, plan, capture_note_versions).await,
565 };
566 match applied {
567 Ok(applied) => {
568 release_savepoint(writer, &savepoint).await?;
569 if let Some(effect) = applied.effect {
570 post_commit.push(effect);
571 }
572 if let Some(version) = applied.note_version {
573 note_versions.push(version);
574 }
575 }
576 Err(failure) => {
577 let _ = rollback_to_savepoint(writer, &savepoint).await;
585 let _ = release_savepoint(writer, &savepoint).await;
586 return Err(PreparedAtomicError::Refused {
587 failure: (op_index, failure),
588 message: format!("ADR-099 atomic unit aborted at op {op_index}"),
589 });
590 }
591 }
592 }
593 Ok((note_versions, post_commit))
594 })
595 });
596
597 match run_prepared_atomic_unit(access, op).await {
598 Ok(PreparedAtomicOutcome::Committed { value, post_commit }) => {
599 for (_, plan) in finalizers {
600 plan.finish_commit();
601 }
602 Ok((AtomicRunOutcome::Committed { post_commit }, value))
603 }
604 Ok(PreparedAtomicOutcome::RolledBack((failed_op_index, failure))) => {
605 let mut native_source = None;
606 for (index, plan) in finalizers {
607 if index <= failed_op_index {
608 if let Some(source) = plan.finish_rollback(access).await {
609 native_source = Some(source);
610 }
611 }
612 }
613 if let Some(source) = native_source {
614 return Err(AtomicRunnerError(StorageError::WriterTaskRequestFailed {
615 request_state: khive_storage::WriterTaskRequestState::TransactionRolledBack,
616 source: Box::new(source),
617 }));
618 }
619 Ok((
620 AtomicRunOutcome::RolledBack {
621 failed_op_index,
622 failure,
623 },
624 Vec::new(),
625 ))
626 }
627 Err(error) => {
628 if matches!(
631 &error,
632 StorageError::WriterTaskRequestFailed {
633 request_state: khive_storage::WriterTaskRequestState::TransactionRolledBack,
634 ..
635 }
636 ) {
637 for (_, plan) in finalizers {
638 let _ = plan.finish_rollback(access).await;
639 }
640 }
641 Err(AtomicRunnerError(error))
642 }
643 }
644}
645
646pub(crate) enum PreparedAtomicError<E> {
647 Refused { failure: E, message: String },
648 Storage(StorageError),
649}
650
651impl<E> From<StorageError> for PreparedAtomicError<E> {
652 fn from(error: StorageError) -> Self {
653 Self::Storage(error)
654 }
655}
656
657pub(crate) enum PreparedAtomicOutcome<T, E> {
658 Committed {
659 value: T,
660 post_commit: CommittedPostCommitEffects,
661 },
662 RolledBack(E),
663}
664
665pub(crate) type PreparedAtomicOp<T, E> = Box<
666 dyn for<'w> FnOnce(
667 &'w mut dyn SqlWriter,
668 )
669 -> BoxFuture<'w, Result<(T, Vec<PostCommitEffect>), PreparedAtomicError<E>>>
670 + Send,
671>;
672
673pub(crate) async fn run_prepared_atomic_unit<T: Send + 'static, E: Send + 'static>(
676 access: &dyn SqlAccess,
677 prepared: PreparedAtomicOp<T, E>,
678) -> Result<PreparedAtomicOutcome<T, E>, StorageError> {
679 let failure_slot = Arc::new(Mutex::new(None));
680 let recorded = Arc::clone(&failure_slot);
681 let op: AtomicUnitOp = Box::new(move |writer| {
682 Box::pin(async move {
683 match prepared(writer).await {
684 Ok(value) => Ok(Box::new(value) as Box<dyn Any + Send>),
685 Err(PreparedAtomicError::Storage(error)) => Err(error),
686 Err(PreparedAtomicError::Refused { failure, message }) => {
687 *recorded
688 .lock()
689 .expect("atomic runner failure slot poisoned") = Some(failure);
690 Err(StorageError::Internal(message))
691 }
692 }
693 })
694 });
695 match access.atomic_unit(op).await {
696 Ok(boxed) => {
697 let (value, effects) = *boxed
698 .downcast::<(T, Vec<PostCommitEffect>)>()
699 .map_err(|_| StorageError::Internal("invalid prepared atomic outcome".into()))?;
700 Ok(PreparedAtomicOutcome::Committed {
701 value,
702 post_commit: CommittedPostCommitEffects::new(effects),
703 })
704 }
705 Err(storage_err) => {
706 if !atomic_unit_error_allows_recorded_refusal(&storage_err) {
708 return Err(storage_err);
709 }
710 let recorded = failure_slot
711 .lock()
712 .expect("atomic runner failure slot poisoned")
713 .take();
714 match recorded {
715 Some(failure) => Ok(PreparedAtomicOutcome::RolledBack(failure)),
716 None => Err(storage_err),
717 }
718 }
719 }
720}
721
722pub(crate) fn atomic_unit_error_allows_recorded_refusal(error: &StorageError) -> bool {
723 match error.without_sqlite_write_stage() {
724 StorageError::WriterTaskTerminated { .. } => false,
725 StorageError::WriterTaskRequestFailed { request_state, .. } => {
726 *request_state == khive_storage::WriterTaskRequestState::TransactionRolledBack
727 }
728 _ => true,
729 }
730}
731
732#[cfg(test)]
733#[path = "atomic_runner_tests.rs"]
734mod tests;