saga.rs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. //! Saga helpers for the ledger.
  2. //!
  3. //! Two things live here:
  4. //!
  5. //! # Dumb-storage count contract
  6. //!
  7. //! `verify_postings`, `verify_posting_states`, and `ensure` interpret the
  8. //! affected-row counts the dumb store returns (ADR-0003): a full count is
  9. //! success, a short count is tolerated only when the postings already reached
  10. //! the intended end-state, and any other mismatch is a genuine fault. The
  11. //! single-commit pipeline (`reserve → finalize`) is a plain function in
  12. //! [`crate::ledger`], not a `legend` saga, and uses these helpers directly.
  13. //!
  14. //! # High-level composition
  15. //!
  16. //! [`PayMovementStep`] and [`DepositMovementStep`] are [`Step`] implementations
  17. //! over the intent-layer API (each wraps `commit()`), so several transfers can
  18. //! be combined into one multi-transfer saga via `legend!`, with automatic LIFO
  19. //! compensation (reversal) across the workflow.
  20. use std::sync::Arc;
  21. use async_trait::async_trait;
  22. use legend::step::{CompensationOutcome, Step, StepOutcome};
  23. use serde::{Deserialize, Serialize};
  24. use kuatia_core::{AccountId, AssetId, Cent, PostingId, PostingState, Receipt, TransferBuilder};
  25. use crate::error::LedgerError;
  26. use crate::ledger::Ledger;
  27. use kuatia_storage::error::StoreError;
  28. use kuatia_storage::store::Store;
  29. /// A saga-internal plumbing fault (missing context, a short row-count that the
  30. /// end-state does not explain). These are genuine internal invariants, distinct
  31. /// from the typed domain errors ([`LedgerError::Validation`], overdraft, frozen)
  32. /// that flow through unchanged, so they map to [`StoreError::Internal`].
  33. fn internal(message: impl Into<String>) -> LedgerError {
  34. LedgerError::Store(StoreError::Internal(message.into()))
  35. }
  36. /// Fail with an internal fault unless `condition` holds. Flattens the recurring
  37. /// "re-read after a dumb write and assert the end-state, else it's a genuine
  38. /// storage fault" check into one line at each finalize step.
  39. pub(crate) fn ensure(condition: bool, what: impl Into<String>) -> Result<(), LedgerError> {
  40. if condition {
  41. Ok(())
  42. } else {
  43. Err(internal(what))
  44. }
  45. }
  46. /// Re-read the derived states of `ids` and confirm every one satisfies `ok`
  47. /// (and that all are present). This is the end-state half of the dumb-storage
  48. /// count contract: the store applied some rows and returned a count; here the
  49. /// saga confirms the postings actually reached the state it intended, treating
  50. /// any mismatch as a genuine fault (contended or concurrently modified).
  51. pub(crate) async fn verify_posting_states(
  52. store: &dyn Store,
  53. ids: &[PostingId],
  54. ok: impl Fn(&PostingState) -> bool,
  55. what: &str,
  56. ) -> Result<(), LedgerError> {
  57. let states = store
  58. .get_posting_states(ids)
  59. .await
  60. .map_err(LedgerError::Store)?;
  61. ensure(
  62. states.len() == ids.len() && states.iter().all(&ok),
  63. format!(
  64. "{what}: posting end-state not satisfied ({}/{} rows)",
  65. states.len(),
  66. ids.len()
  67. ),
  68. )
  69. }
  70. /// Interpret a dumb primitive's affected-row `count` against the `ids` it
  71. /// targeted. `count == ids.len()` is success. A short count is acceptable only if
  72. /// the shortfall is already in the desired end-state — a prior attempt (or this
  73. /// saga, replayed by recovery) already applied it — verified by
  74. /// [`verify_posting_states`]. Otherwise it is a genuine failure (contended or
  75. /// concurrently modified) and the caller compensates.
  76. pub(crate) async fn verify_postings(
  77. store: &dyn Store,
  78. ids: &[PostingId],
  79. count: u64,
  80. ok: impl Fn(&PostingState) -> bool,
  81. what: &str,
  82. ) -> Result<(), LedgerError> {
  83. if count == ids.len() as u64 {
  84. return Ok(());
  85. }
  86. verify_posting_states(store, ids, ok, what).await
  87. }
  88. // ---------------------------------------------------------------------------
  89. // Saga context -- carries the ledger handle + state between steps
  90. // ---------------------------------------------------------------------------
  91. /// Saga context that wraps a ledger and collects the receipts a multi-transfer
  92. /// saga produces across its steps.
  93. ///
  94. /// The ledger handle is `#[serde(skip)]` -- after deserializing a paused
  95. /// execution you must call [`inject_ledger`](LedgerCtx::inject_ledger)
  96. /// before resuming.
  97. #[derive(Clone, Serialize, Deserialize)]
  98. pub struct LedgerCtx {
  99. /// Receipts collected from completed steps.
  100. pub receipts: Vec<Receipt>,
  101. #[serde(skip)]
  102. ledger: Option<Arc<Ledger>>,
  103. }
  104. impl std::fmt::Debug for LedgerCtx {
  105. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  106. f.debug_struct("LedgerCtx")
  107. .field("receipts", &self.receipts)
  108. .field("ledger_present", &self.ledger.is_some())
  109. .finish()
  110. }
  111. }
  112. impl LedgerCtx {
  113. /// Create a new context wrapping the given ledger.
  114. pub fn new(ledger: Arc<Ledger>) -> Self {
  115. Self {
  116. receipts: Vec::new(),
  117. ledger: Some(ledger),
  118. }
  119. }
  120. /// Re-inject the ledger handle after deserializing a paused execution.
  121. pub fn inject_ledger(&mut self, ledger: Arc<Ledger>) {
  122. self.ledger = Some(ledger);
  123. }
  124. /// Borrow the ledger, returning an error if not injected.
  125. pub fn ledger(&self) -> Result<&Ledger, LedgerError> {
  126. self.ledger.as_ref().map(|l| l.as_ref()).ok_or_else(|| {
  127. internal("ledger not injected -- call inject_ledger() after deserializing")
  128. })
  129. }
  130. /// Clone the ledger `Arc`, returning an error if not injected.
  131. pub fn ledger_arc(&self) -> Result<Arc<Ledger>, LedgerError> {
  132. self.ledger.clone().ok_or_else(|| {
  133. internal("ledger not injected -- call inject_ledger() after deserializing")
  134. })
  135. }
  136. }
  137. // ===========================================================================
  138. // High-level steps (pay / deposit movement steps)
  139. // ===========================================================================
  140. /// Input for the pay movement saga step.
  141. #[derive(Debug, Clone, Serialize, Deserialize)]
  142. pub struct PayInput {
  143. /// Source account.
  144. pub from: AccountId,
  145. /// Destination account.
  146. pub to: AccountId,
  147. /// Asset to transfer.
  148. pub asset: AssetId,
  149. /// Amount to transfer.
  150. pub amount: Cent,
  151. }
  152. /// Input for the deposit movement saga step.
  153. #[derive(Debug, Clone, Serialize, Deserialize)]
  154. pub struct DepositInput {
  155. /// Account receiving the deposit.
  156. pub to: AccountId,
  157. /// Asset being deposited.
  158. pub asset: AssetId,
  159. /// Amount to deposit.
  160. pub amount: Cent,
  161. /// External account funding the deposit.
  162. pub external: AccountId,
  163. }
  164. // ---------------------------------------------------------------------------
  165. // Helpers
  166. // ---------------------------------------------------------------------------
  167. async fn compensate_last_receipt(ctx: &mut LedgerCtx) -> Result<CompensationOutcome, LedgerError> {
  168. let receipt = ctx
  169. .receipts
  170. .pop()
  171. .ok_or_else(|| internal("no receipt to compensate"))?;
  172. ctx.ledger_arc()?.reverse(&receipt.transfer_id).await?;
  173. Ok(CompensationOutcome::Completed)
  174. }
  175. // ---------------------------------------------------------------------------
  176. // Steps
  177. // ---------------------------------------------------------------------------
  178. /// Saga step: pay between two accounts via a single-movement transfer.
  179. pub struct PayMovementStep;
  180. #[async_trait]
  181. impl Step<LedgerCtx, LedgerError> for PayMovementStep {
  182. type Input = PayInput;
  183. async fn execute(ctx: &mut LedgerCtx, input: &PayInput) -> Result<StepOutcome, LedgerError> {
  184. let ledger = ctx.ledger_arc()?;
  185. let transfer = TransferBuilder::new()
  186. .pay(input.from, input.to, input.asset, input.amount)
  187. .build();
  188. let receipt = ledger.commit(transfer).await?;
  189. ctx.receipts.push(receipt);
  190. Ok(StepOutcome::Continue)
  191. }
  192. async fn compensate(
  193. ctx: &mut LedgerCtx,
  194. _input: &PayInput,
  195. ) -> Result<CompensationOutcome, LedgerError> {
  196. compensate_last_receipt(ctx).await
  197. }
  198. }
  199. /// Saga step: deposit value from an external account via a single-movement transfer.
  200. pub struct DepositMovementStep;
  201. #[async_trait]
  202. impl Step<LedgerCtx, LedgerError> for DepositMovementStep {
  203. type Input = DepositInput;
  204. async fn execute(
  205. ctx: &mut LedgerCtx,
  206. input: &DepositInput,
  207. ) -> Result<StepOutcome, LedgerError> {
  208. let ledger = ctx.ledger_arc()?;
  209. let transfer = TransferBuilder::new()
  210. .deposit(input.to, input.asset, input.amount, input.external)
  211. .map_err(LedgerError::from)?
  212. .build();
  213. let receipt = ledger.commit(transfer).await?;
  214. ctx.receipts.push(receipt);
  215. Ok(StepOutcome::Continue)
  216. }
  217. async fn compensate(
  218. ctx: &mut LedgerCtx,
  219. _input: &DepositInput,
  220. ) -> Result<CompensationOutcome, LedgerError> {
  221. compensate_last_receipt(ctx).await
  222. }
  223. }