|
|
@@ -1,39 +1,30 @@
|
|
|
-//! Legend saga step adapters for the ledger.
|
|
|
+//! Saga helpers for the ledger.
|
|
|
//!
|
|
|
-//! Provides [`Step`] implementations so the ledger can participate
|
|
|
-//! in multi-resource saga workflows, with automatic LIFO compensation across
|
|
|
-//! resource boundaries.
|
|
|
+//! Two things live here:
|
|
|
//!
|
|
|
-//! # Envelope pipeline saga
|
|
|
+//! # Dumb-storage count contract
|
|
|
//!
|
|
|
-//! A commit is two saga steps over a pre-resolved [`Envelope`] (resolution runs
|
|
|
-//! before the saga, in `Ledger::commit`):
|
|
|
-//!
|
|
|
-//! 1. **ReservePostingsStep** -- `reserve_postings`: move each consumed posting from the active index into the reserved index under the saga's `ReservationId`; interprets the count via `verify_postings`.
|
|
|
-//! 2. **FinalizeTransferStep** -- delegates to `Ledger::finalize_envelope`, which re-validates against current state (the last-step floor / freeze-close guard), marks the saga `Finalizing`, then runs the dumb primitives (`deactivate_postings` → `insert_postings` → `store_transfer` → `append_event`) verifying every end-state.
|
|
|
-//!
|
|
|
-//! The `EnvelopeSaga` is defined via `legend!` in `ledger.rs` and driven by
|
|
|
-//! `commit_envelope()`. Crash recovery (`Ledger::recover`) re-completes a
|
|
|
-//! persisted saga using its persisted phase: a `Reserving` saga is re-run
|
|
|
-//! (re-validating); a `Finalizing` saga is rolled forward through the same
|
|
|
-//! verified `finalize_envelope`.
|
|
|
+//! `verify_postings`, `verify_posting_states`, and `ensure` interpret the
|
|
|
+//! affected-row counts the dumb store returns (ADR-0003): a full count is
|
|
|
+//! success, a short count is tolerated only when the postings already reached
|
|
|
+//! the intended end-state, and any other mismatch is a genuine fault. The
|
|
|
+//! single-commit pipeline (`reserve → finalize`) is a plain function in
|
|
|
+//! [`crate::ledger`], not a `legend` saga, and uses these helpers directly.
|
|
|
//!
|
|
|
//! # High-level composition
|
|
|
//!
|
|
|
-//! High-level steps (`PayMovementStep` and `DepositMovementStep`) compose over
|
|
|
-//! the intent-layer API and can be combined into multi-transfer sagas via `legend!`.
|
|
|
+//! [`PayMovementStep`] and [`DepositMovementStep`] are [`Step`] implementations
|
|
|
+//! over the intent-layer API (each wraps `commit()`), so several transfers can
|
|
|
+//! be combined into one multi-transfer saga via `legend!`, with automatic LIFO
|
|
|
+//! compensation (reversal) across the workflow.
|
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
use async_trait::async_trait;
|
|
|
-use legend::step::{CompensationOutcome, RetryPolicy, Step, StepOutcome};
|
|
|
+use legend::step::{CompensationOutcome, Step, StepOutcome};
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
-use tracing::Instrument;
|
|
|
|
|
|
-use kuatia_core::{
|
|
|
- AccountId, AssetId, Cent, Envelope, PostingId, PostingState, Receipt, ReservationId,
|
|
|
- TransferBuilder,
|
|
|
-};
|
|
|
+use kuatia_core::{AccountId, AssetId, Cent, PostingId, PostingState, Receipt, TransferBuilder};
|
|
|
|
|
|
use crate::error::LedgerError;
|
|
|
use crate::ledger::Ledger;
|
|
|
@@ -48,13 +39,49 @@ fn internal(message: impl Into<String>) -> LedgerError {
|
|
|
LedgerError::Store(StoreError::Internal(message.into()))
|
|
|
}
|
|
|
|
|
|
+/// Fail with an internal fault unless `condition` holds. Flattens the recurring
|
|
|
+/// "re-read after a dumb write and assert the end-state, else it's a genuine
|
|
|
+/// storage fault" check into one line at each finalize step.
|
|
|
+pub(crate) fn ensure(condition: bool, what: impl Into<String>) -> Result<(), LedgerError> {
|
|
|
+ if condition {
|
|
|
+ Ok(())
|
|
|
+ } else {
|
|
|
+ Err(internal(what))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// Re-read the derived states of `ids` and confirm every one satisfies `ok`
|
|
|
+/// (and that all are present). This is the end-state half of the dumb-storage
|
|
|
+/// count contract: the store applied some rows and returned a count; here the
|
|
|
+/// saga confirms the postings actually reached the state it intended, treating
|
|
|
+/// any mismatch as a genuine fault (contended or concurrently modified).
|
|
|
+pub(crate) async fn verify_posting_states(
|
|
|
+ store: &dyn Store,
|
|
|
+ ids: &[PostingId],
|
|
|
+ ok: impl Fn(&PostingState) -> bool,
|
|
|
+ what: &str,
|
|
|
+) -> Result<(), LedgerError> {
|
|
|
+ let states = store
|
|
|
+ .get_posting_states(ids)
|
|
|
+ .await
|
|
|
+ .map_err(LedgerError::Store)?;
|
|
|
+ ensure(
|
|
|
+ states.len() == ids.len() && states.iter().all(&ok),
|
|
|
+ format!(
|
|
|
+ "{what}: posting end-state not satisfied ({}/{} rows)",
|
|
|
+ states.len(),
|
|
|
+ ids.len()
|
|
|
+ ),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
/// Interpret a dumb primitive's affected-row `count` against the `ids` it
|
|
|
/// targeted. `count == ids.len()` is success. A short count is acceptable only if
|
|
|
/// the shortfall is already in the desired end-state — a prior attempt (or this
|
|
|
-/// saga, replayed by recovery) already applied it — verified by reading the
|
|
|
-/// postings and checking `ok`. Otherwise it is a genuine failure (contended or
|
|
|
-/// concurrently modified) and the saga compensates.
|
|
|
-async fn verify_postings(
|
|
|
+/// saga, replayed by recovery) already applied it — verified by
|
|
|
+/// [`verify_posting_states`]. Otherwise it is a genuine failure (contended or
|
|
|
+/// concurrently modified) and the caller compensates.
|
|
|
+pub(crate) async fn verify_postings(
|
|
|
store: &dyn Store,
|
|
|
ids: &[PostingId],
|
|
|
count: u64,
|
|
|
@@ -64,24 +91,15 @@ async fn verify_postings(
|
|
|
if count == ids.len() as u64 {
|
|
|
return Ok(());
|
|
|
}
|
|
|
- let states = store
|
|
|
- .get_posting_states(ids)
|
|
|
- .await
|
|
|
- .map_err(LedgerError::Store)?;
|
|
|
- if states.len() == ids.len() && states.iter().all(&ok) {
|
|
|
- return Ok(());
|
|
|
- }
|
|
|
- Err(internal(format!(
|
|
|
- "{what}: storage applied {count}/{} rows and the end-state is not satisfied",
|
|
|
- ids.len()
|
|
|
- )))
|
|
|
+ verify_posting_states(store, ids, ok, what).await
|
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
// Saga context -- carries the ledger handle + state between steps
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
-/// Saga context that wraps a ledger and tracks state across steps.
|
|
|
+/// Saga context that wraps a ledger and collects the receipts a multi-transfer
|
|
|
+/// saga produces across its steps.
|
|
|
///
|
|
|
/// The ledger handle is `#[serde(skip)]` -- after deserializing a paused
|
|
|
/// execution you must call [`inject_ledger`](LedgerCtx::inject_ledger)
|
|
|
@@ -90,13 +108,6 @@ async fn verify_postings(
|
|
|
pub struct LedgerCtx {
|
|
|
/// Receipts collected from completed steps.
|
|
|
pub receipts: Vec<Receipt>,
|
|
|
- /// Posting ids reserved so far (for compensation).
|
|
|
- pub reserved_postings: Vec<PostingId>,
|
|
|
- /// Resolved envelope produced by the resolve step.
|
|
|
- pub envelope: Option<Envelope>,
|
|
|
- /// Reservation owner token for this saga's reserved postings. Serialized so
|
|
|
- /// it survives pause/recovery, keeping ownership stable across restarts.
|
|
|
- pub reservation: ReservationId,
|
|
|
#[serde(skip)]
|
|
|
ledger: Option<Arc<Ledger>>,
|
|
|
}
|
|
|
@@ -105,8 +116,6 @@ impl std::fmt::Debug for LedgerCtx {
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
f.debug_struct("LedgerCtx")
|
|
|
.field("receipts", &self.receipts)
|
|
|
- .field("reserved_postings", &self.reserved_postings.len())
|
|
|
- .field("has_envelope", &self.envelope.is_some())
|
|
|
.field("ledger_present", &self.ledger.is_some())
|
|
|
.finish()
|
|
|
}
|
|
|
@@ -117,25 +126,6 @@ impl LedgerCtx {
|
|
|
pub fn new(ledger: Arc<Ledger>) -> Self {
|
|
|
Self {
|
|
|
receipts: Vec::new(),
|
|
|
- reserved_postings: Vec::new(),
|
|
|
- envelope: None,
|
|
|
- reservation: ReservationId::default(),
|
|
|
- ledger: Some(ledger),
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- /// Create a context for the envelope pipeline (reserve → finalize; finalize re-validates)
|
|
|
- /// with a pre-resolved envelope and an explicit reservation.
|
|
|
- pub fn for_envelope(
|
|
|
- ledger: Arc<Ledger>,
|
|
|
- envelope: Envelope,
|
|
|
- reservation: ReservationId,
|
|
|
- ) -> Self {
|
|
|
- Self {
|
|
|
- receipts: Vec::new(),
|
|
|
- reserved_postings: Vec::new(),
|
|
|
- envelope: Some(envelope),
|
|
|
- reservation,
|
|
|
ledger: Some(ledger),
|
|
|
}
|
|
|
}
|
|
|
@@ -161,142 +151,6 @@ impl LedgerCtx {
|
|
|
}
|
|
|
|
|
|
// ===========================================================================
|
|
|
-// Envelope pipeline steps (reserve -> finalize; resolve runs before the saga, validate inside finalize)
|
|
|
-// ===========================================================================
|
|
|
-
|
|
|
-// ---------------------------------------------------------------------------
|
|
|
-// Step 1: ReservePostingsStep
|
|
|
-// ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-/// Input for the reserve step (posting ids come from ctx.envelope).
|
|
|
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
-pub struct ReserveInput;
|
|
|
-
|
|
|
-/// Reserves consumed postings by CAS: move each from the active index to the
|
|
|
-/// reserved index (the delete-returns-one picks a single winner).
|
|
|
-///
|
|
|
-/// Gets the posting ids from the resolved envelope in the context.
|
|
|
-/// Compensation releases all reserved postings back to Active.
|
|
|
-pub struct ReservePostingsStep;
|
|
|
-
|
|
|
-#[async_trait]
|
|
|
-impl Step<LedgerCtx, LedgerError> for ReservePostingsStep {
|
|
|
- type Input = ReserveInput;
|
|
|
-
|
|
|
- async fn execute(
|
|
|
- ctx: &mut LedgerCtx,
|
|
|
- _input: &ReserveInput,
|
|
|
- ) -> Result<StepOutcome, LedgerError> {
|
|
|
- async {
|
|
|
- let posting_ids: Vec<PostingId> = ctx
|
|
|
- .envelope
|
|
|
- .as_ref()
|
|
|
- .ok_or_else(|| internal("no envelope in context -- resolve step must run first"))?
|
|
|
- .consumes()
|
|
|
- .to_vec();
|
|
|
- let rid = ctx.reservation;
|
|
|
- let ledger = ctx.ledger_arc()?;
|
|
|
- let store = ledger.store();
|
|
|
-
|
|
|
- let reserved = store
|
|
|
- .reserve_postings(&posting_ids, rid)
|
|
|
- .await
|
|
|
- .map_err(LedgerError::Store)?;
|
|
|
- // Storage reports the count; the saga decides. A short count is fine
|
|
|
- // only if the shortfall is already reserved by us (idempotent replay).
|
|
|
- verify_postings(
|
|
|
- store,
|
|
|
- &posting_ids,
|
|
|
- reserved,
|
|
|
- |s| matches!(s, PostingState::Reserved(r) if *r == rid),
|
|
|
- "reserve",
|
|
|
- )
|
|
|
- .await?;
|
|
|
- ctx.reserved_postings.extend_from_slice(&posting_ids);
|
|
|
- Ok(StepOutcome::Continue)
|
|
|
- }
|
|
|
- .instrument(tracing::info_span!("saga_step", step = "reserve"))
|
|
|
- .await
|
|
|
- }
|
|
|
-
|
|
|
- async fn compensate(
|
|
|
- ctx: &mut LedgerCtx,
|
|
|
- _input: &ReserveInput,
|
|
|
- ) -> Result<CompensationOutcome, LedgerError> {
|
|
|
- ctx.ledger()?
|
|
|
- .store()
|
|
|
- .release_postings(&ctx.reserved_postings, ctx.reservation)
|
|
|
- .await
|
|
|
- .map_err(LedgerError::Store)?;
|
|
|
- ctx.reserved_postings.clear();
|
|
|
- Ok(CompensationOutcome::Completed)
|
|
|
- }
|
|
|
-
|
|
|
- fn retry_policy() -> RetryPolicy {
|
|
|
- RetryPolicy::retries(3)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ---------------------------------------------------------------------------
|
|
|
-// Step 2: FinalizeTransferStep
|
|
|
-// ---------------------------------------------------------------------------
|
|
|
-
|
|
|
-/// Input for the finalize step (envelope comes from ctx).
|
|
|
-#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
-pub struct FinalizeInput;
|
|
|
-
|
|
|
-/// Re-validates against current state (the last-step floor / freeze-close guard),
|
|
|
-/// then drives the verified, idempotent commit via `Ledger::finalize_envelope`.
|
|
|
-///
|
|
|
-/// Compensation reverses the finalized envelope (only relevant once committed).
|
|
|
-pub struct FinalizeTransferStep;
|
|
|
-
|
|
|
-#[async_trait]
|
|
|
-impl Step<LedgerCtx, LedgerError> for FinalizeTransferStep {
|
|
|
- type Input = FinalizeInput;
|
|
|
-
|
|
|
- async fn execute(
|
|
|
- ctx: &mut LedgerCtx,
|
|
|
- _input: &FinalizeInput,
|
|
|
- ) -> Result<StepOutcome, LedgerError> {
|
|
|
- async {
|
|
|
- let envelope = ctx
|
|
|
- .envelope
|
|
|
- .clone()
|
|
|
- .ok_or_else(|| internal("no envelope in context -- resolve step must run first"))?;
|
|
|
- let rid = ctx.reservation;
|
|
|
- let ledger = ctx.ledger_arc()?;
|
|
|
-
|
|
|
- // All commit work (re-validate, mark Finalizing, deactivate/insert/
|
|
|
- // store/event with end-state verification) lives in `finalize_envelope`
|
|
|
- // so recovery uses exactly the same path. Its typed error (validation,
|
|
|
- // overdraft, frozen) reaches the caller unchanged.
|
|
|
- let receipt = ledger.finalize_envelope(&envelope, rid).await?;
|
|
|
-
|
|
|
- ctx.receipts.push(receipt);
|
|
|
- ctx.reserved_postings.clear();
|
|
|
- Ok(StepOutcome::Continue)
|
|
|
- }
|
|
|
- .instrument(tracing::info_span!("saga_step", step = "finalize"))
|
|
|
- .await
|
|
|
- }
|
|
|
-
|
|
|
- async fn compensate(
|
|
|
- ctx: &mut LedgerCtx,
|
|
|
- _input: &FinalizeInput,
|
|
|
- ) -> Result<CompensationOutcome, LedgerError> {
|
|
|
- if let Some(receipt) = ctx.receipts.pop() {
|
|
|
- ctx.ledger_arc()?.reverse(&receipt.transfer_id).await?;
|
|
|
- }
|
|
|
- Ok(CompensationOutcome::Completed)
|
|
|
- }
|
|
|
-
|
|
|
- fn retry_policy() -> RetryPolicy {
|
|
|
- RetryPolicy::retries(3)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// ===========================================================================
|
|
|
// High-level steps (pay / deposit movement steps)
|
|
|
// ===========================================================================
|
|
|
|