Skip to main content

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//! ADR-099 B3 caller: `kkernel exec --ops-file --atomic` — async prepare,
8//! then this runner's one synchronous commit pass, then async post-commit
9//! effects. Runtime callers also supply prepared plans directly (hard-delete
10//! in `operations.rs`). See `docs/api/atomic_runner.md` for the full
11//! three-phase shape (ADR-099 D1).
12//!
13//! # Safety: suspend-free invariant
14//!
15//! `run_prepared_atomic_unit` owns the failure and commit protocol shared by
16//! the atomic-plan and stream-batch paths. It builds an [`AtomicUnitOp`]
17//! closure for [`SqlAccess::atomic_unit`], whose contract
18//! requires the closure's future to resolve on its first poll — synchronous
19//! DML against the provided `&mut dyn SqlWriter` only, never a suspending
20//! `.await`. Prepared callbacks drive only parameterized statements, guards
21//! and stream-ledger reads through the provided writer. They must not perform
22//! asynchronous preparation or other I/O. The paired
23//! suspend-trap tests at the bottom of this file check both the happy-path
24//! (real commit pass resolves on first poll) and the misuse-is-caught case
25//! (a hand-built suspending closure fails loudly through the same seam). See
26//! `docs/api/atomic_runner.md#suspend-free-invariant` for the full argument.
27
28use 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/// One admissible op's prepared write plan (ADR-099 D3's v1 admissible verb
39/// groups), ready for the commit pass. This is the `Vec<AtomicOpPlan>` shape
40/// [`run_atomic_unit`] consumes — the runner is agnostic to which verb
41/// produced a given plan; it only needs each plan's ordered statements
42/// (`plan_statements`) and any deferred post-commit effect
43/// (`post_commit_effect`).
44#[derive(Debug, Clone)]
45pub enum AtomicOpPlan {
46    /// An admitted canonical operation materialized using the applying writer.
47    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    /// This op's statements, in the order the commit pass must apply them.
62    ///
63    /// For [`AtomicOpPlan::Merge`], the predicate-based rewires
64    /// (`MergePlan::rewires`) come first, converted to unguarded
65    /// [`PlanStatement`]s (ADR-099 D1 rule 1 — a rewire may legitimately
66    /// touch zero or many rows and is never guarded), followed by the
67    /// always-guarded lifecycle writes (`MergePlan::lifecycle`). Every other
68    /// variant carries ordered statements. Updates additionally append typed graph
69    /// effects; the tuple flag marks a read assertion for `query_all`, never DML.
70    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            // The opaque finalizer applies its own phased statements.
75            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    /// The deferred post-commit effect this op's plan recorded, if any.
114    ///
115    /// [`UpdatePlan`] carries a [`PostCommitEffect`] field for the `update`
116    /// reindex caveat (ADR-099 D3). Under B3 (GAP-5),
117    /// [`GtdTransitionPlan`]/[`GtdCompletePlan`] also carry one, for the
118    /// best-effort lifecycle audit row. Since #750, [`DeletePlan`] also
119    /// carries one, for the note-mutation
120    /// hook fire on a committed note delete. Every other admissible verb's
121    /// apply is pure DML with no deferred side effect. `merge`'s existing
122    /// post-transaction vector re-insert (D3: "merge already performs its
123    /// vector re-insert after its transaction") is the handler's own
124    /// pre-existing behavior outside this plan shape — [`MergePlan`] itself
125    /// carries no `post_commit` field, so this runner records none for it.
126    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/// Why a single op's plan failed inside the commit pass (ADR-099 acceptance
152/// criteria: "the failing op index is recorded").
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum AtomicOpFailure {
155    /// The touched captured value differs from the applying writer record.
156    PreimageMismatch {
157        target_id: uuid::Uuid,
158        field_path: String,
159    },
160    /// An independent owning-kind admission snapshot became stale.
161    OwnerSnapshotConflict {
162        target_id: uuid::Uuid,
163        kind: String,
164    },
165    NoteConflict(crate::note_write::NoteWriteConflict),
166    EntityConflict(crate::entity_write::EntityVersionConflict),
167    /// The statement executed without a SQL error, but its affected-row
168    /// count did not satisfy the guard prepare attached to it (ADR-099 D1
169    /// rule 2 — "a prepare-time validation is a plan hypothesis, re-verified
170    /// under the transaction, never a commitment"). This is the shape both
171    /// the dangling-edge and zero-row acceptance criteria trip.
172    GuardFailed {
173        statement_label: Option<String>,
174        expected: AffectedRowGuard,
175        observed: u64,
176    },
177    /// The statement itself returned a storage error (a genuine SQL
178    /// failure — malformed SQL, a constraint violation not modeled as a
179    /// guard, etc.), not a guard mismatch.
180    SqlError {
181        statement_label: Option<String>,
182        message: String,
183    },
184}
185
186/// Deferred effects whose owning atomic unit has committed successfully.
187///
188/// Only the shared prepared-unit commit owner constructs this token.
189/// Consumers may inspect
190/// its effects through [`CommittedPostCommitEffects::as_slice`], while the
191/// phase-3 executor takes the token by value; no public API exposes the owned
192/// effect collection or accepts a prepare-time [`PostCommitEffect`] in its
193/// place.
194///
195/// A committed token is a one-shot capability and cannot be cloned for
196/// replay:
197///
198/// ```compile_fail
199/// use khive_runtime::CommittedPostCommitEffects;
200///
201/// fn duplicate(
202///     committed: &CommittedPostCommitEffects,
203/// ) -> CommittedPostCommitEffects {
204///     committed.clone()
205/// }
206/// ```
207#[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    /// Inspect the committed effects without discarding their commit
218    /// provenance.
219    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/// The whole-unit outcome of a completed [`run_atomic_unit`] call — the
229/// commit pass ran to a clean, distinguishable verdict (never returned for
230/// a seam-level failure; see [`AtomicRunnerError`] for that case).
231#[derive(Debug, PartialEq, Eq)]
232pub enum AtomicRunOutcome {
233    /// Every op's plan applied and the unit committed. Carries an opaque
234    /// [`CommittedPostCommitEffects`] token containing the deferred effects
235    /// in op order. Phase 3 consumes that token through
236    /// [`crate::atomic_prepare::apply_post_commit_effects`] after
237    /// `run_atomic_unit` returns and outside any transaction; callers can
238    /// inspect the effects without converting them back into an executable
239    /// raw collection.
240    Committed {
241        post_commit: CommittedPostCommitEffects,
242    },
243    /// The op at `failed_op_index` failed; the whole unit rolled back
244    /// (ADR-099 D1: "the whole unit rolls back and the failing op index is
245    /// recorded"). No op's writes are present in the database after this
246    /// outcome — including any earlier op whose own `SAVEPOINT` had already
247    /// been `RELEASE`d, because `RELEASE` only merges a savepoint's changes
248    /// into its parent transaction; it does not commit them independently
249    /// of the outer `atomic_unit` transaction's own COMMIT/ROLLBACK.
250    RolledBack {
251        failed_op_index: usize,
252        failure: AtomicOpFailure,
253    },
254}
255
256/// A failure of the `atomic_unit` seam itself — the storage layer refused
257/// or could not complete the call at all (read-only backend, no async
258/// runtime for the writer-task lookup, or an [`AtomicUnitOp`] that violated
259/// the suspend-free invariant and got caught by `block_on_sync`). Never
260/// returned for an ordinary op-level guard or SQL failure inside the unit —
261/// those surface as `AtomicRunOutcome::RolledBack`, not this error.
262#[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
296/// Apply one op's plan statements in order, checking each guarded
297/// statement's affected-row count (result-row count for a GTD no-op assertion)
298/// before moving to the next (ADR-099 D1
299/// rule 2). Returns on the first statement that either errors or fails its
300/// guard — never applies a later statement once an earlier one in the same
301/// plan has failed.
302pub(crate) struct AppliedPlan {
303    pub(crate) effect: Option<PostCommitEffect>,
304    pub(crate) note_version: Option<i64>,
305}
306
307/// Original runner projection. The lint caller retains native storage errors
308/// through the shared executor instead of converting them to message strings.
309pub(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        // No-op GTD plans are typed read assertions, not equal-value writes:
417        // even assigning a column to itself would fire the version trigger.
418        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
502/// Run `plans` as ONE atomic unit (ADR-099 D1 commit pass): open a single
503/// [`SqlAccess::atomic_unit`], apply every plan's statements under a named
504/// `SAVEPOINT` (`adr099_atomic_op_<n>`), and commit all or roll back all.
505///
506/// **This is the seam the atomic-unit suspend-free invariant governs (see
507/// the module doc comment above).** The closure built here drives only
508/// `AtomicOpPlan::plan_statements` — DML or a typed read assertion — against the writer
509/// `atomic_unit` hands it; it issues no transaction control of its own
510/// (`BEGIN`/`COMMIT`/`ROLLBACK` are owned entirely by `atomic_unit`, exactly
511/// like the existing `execute_batch` contract) beyond the per-op
512/// `SAVEPOINT`/`RELEASE`/`ROLLBACK TO` statements, which are themselves
513/// synchronous DML from SQLite's perspective, not transaction boundaries the
514/// storage layer needs to track.
515///
516/// On the first op whose plan fails (a guard mismatch or a genuine SQL
517/// error), this function does **not** propagate a generic storage error for
518/// that case: it unwinds just that op's own `SAVEPOINT` (best-effort — the
519/// outer `atomic_unit` transaction is rolled back in full regardless, per
520/// ADR-099 D1) and returns `Ok(AtomicRunOutcome::RolledBack { .. })` naming
521/// the failing op and why. [`AtomicRunnerError`] is reserved for a failure
522/// of the `atomic_unit` seam itself — the storage layer refusing or being
523/// unable to run the call at all.
524pub 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
533/// Optional per-AddNote receipts remain private and are returned only on whole
534/// unit commit. Capturing after each op preserves versions for repeated IDs.
535pub(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                        // Best-effort unwind of just this op's own partial
578                        // DML. The outer `atomic_unit` transaction is rolled
579                        // back in full once this closure returns `Err`
580                        // regardless (ADR-099 D1: "the whole unit rolls
581                        // back") — a failure in this cleanup step is not
582                        // itself a correctness gap in the no-partial-state
583                        // guarantee, only a diagnostic nicety.
584                        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            // A lost acknowledgement is not proof of rollback. Only the
629            // storage owner's explicit settled state permits failure audits.
630            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
673/// Recover a recorded refusal only after the storage owner proves rollback;
674/// effects become executable only after that same owner confirms commit.
675pub(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            // A recorded op failure does not prove the outer transaction rolled back.
707            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;