commit.rs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931
  1. //! The write-ahead saga/commit engine: resolve, reserve, finalize, recover.
  2. //!
  3. //! This is the deep core of the ledger. Every commit is the two-step envelope
  4. //! saga (`reserve → finalize`, validation inside finalize) with automatic retry
  5. //! and LIFO compensation. A phase-tracked write-ahead record ([`PendingSaga`])
  6. //! lets [`Ledger::recover`] complete or safely abandon a commit interrupted by a
  7. //! crash.
  8. use std::collections::{HashMap, HashSet};
  9. use std::sync::Arc;
  10. use legend::ExecutionResult;
  11. use tracing::instrument;
  12. use kuatia_core::{
  13. AccountId, AccountSnapshotId, AssetId, Book, Cent, DEFAULT_BOOK, Envelope, EnvelopeBuilder,
  14. EnvelopeId, NewPosting, PlanInput, Posting, PostingFilter, PostingId, PostingState, Receipt,
  15. ResolveInput, Transfer, account_snapshot_id, draft_movements, envelope_id, resolve_envelope,
  16. validate_and_plan,
  17. };
  18. use kuatia_storage::error::StoreError;
  19. use kuatia_storage::events::{LedgerEvent, LedgerEventKind};
  20. use kuatia_storage::store::EnvelopeRecord;
  21. use super::envelope_saga::*;
  22. use super::{Ledger, now_millis};
  23. use crate::error::LedgerError;
  24. use crate::saga::{FinalizeInput, LedgerCtx, ReserveInput, apply_and_verify, verify_postings};
  25. /// Phase of an in-flight commit, persisted with the write-ahead record so
  26. /// recovery knows whether validation has completed.
  27. #[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
  28. enum SagaPhase {
  29. /// Saved before reserve. Validation has not necessarily run, so recovery must
  30. /// re-reserve and re-validate before it can commit.
  31. Reserving,
  32. /// Saved at the start of finalize — after validation passed and just before
  33. /// the consumed postings begin being removed from the reserved index (the
  34. /// point of no return). Recovery rolls forward without re-validating.
  35. Finalizing,
  36. }
  37. /// Write-ahead record for an in-flight commit, persisted via `SagaStore` before
  38. /// the saga mutates anything and removed once it reaches a terminal state. On
  39. /// startup [`Ledger::recover`] completes any that survive a crash.
  40. #[derive(serde::Serialize, serde::Deserialize)]
  41. struct PendingSaga {
  42. envelope: Envelope,
  43. reservation: kuatia_core::ReservationId,
  44. phase: SagaPhase,
  45. }
  46. /// State loaded in phase 1, passed to the pure validation in phase 2.
  47. pub struct LoadedState {
  48. /// Postings being consumed by the envelope.
  49. pub consumed_postings: Vec<Posting>,
  50. /// Accounts referenced by the envelope.
  51. pub accounts: HashMap<AccountId, kuatia_core::Account>,
  52. /// Current balances for all referenced (account, asset) pairs.
  53. pub balances: HashMap<(AccountId, AssetId), Cent>,
  54. /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
  55. pub book: Option<Book>,
  56. }
  57. impl Ledger {
  58. // -----------------------------------------------------------------------
  59. // Three-piece API: load -> plan -> apply
  60. // -----------------------------------------------------------------------
  61. /// Phase 1: load all state needed for validation.
  62. #[instrument(skip(self, envelope), name = "ledger.load")]
  63. pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
  64. let consumed_postings = if envelope.consumes().is_empty() {
  65. vec![]
  66. } else {
  67. self.store.get_postings(envelope.consumes()).await?
  68. };
  69. let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  70. for p in &consumed_postings {
  71. account_ids.push(p.owner);
  72. }
  73. account_ids.sort();
  74. account_ids.dedup();
  75. let account_list = self.store.get_accounts(&account_ids).await?;
  76. let accounts: HashMap<AccountId, _> = account_list.into_iter().map(|a| (a.id, a)).collect();
  77. let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
  78. for p in &consumed_postings {
  79. balance_keys.push((p.owner, p.asset));
  80. }
  81. for np in envelope.creates() {
  82. balance_keys.push((np.owner, np.asset));
  83. }
  84. balance_keys.sort();
  85. balance_keys.dedup();
  86. let mut balances = HashMap::new();
  87. for (account_id, asset_id) in &balance_keys {
  88. let bal = self.compute_balance(account_id, asset_id).await?;
  89. balances.insert((*account_id, *asset_id), bal);
  90. }
  91. // Load the gating book. A missing named (non-default) book is an error;
  92. // a missing default book means "unrestricted" (no policy to enforce).
  93. let book_id = envelope.book();
  94. let book = match self.store.get_book(&book_id).await {
  95. Ok(b) => Some(b),
  96. Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
  97. Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
  98. Err(e) => return Err(e.into()),
  99. };
  100. Ok(LoadedState {
  101. consumed_postings,
  102. accounts,
  103. balances,
  104. book,
  105. })
  106. }
  107. /// Phase 2: run pure validation and produce a plan.
  108. pub fn plan(
  109. &self,
  110. envelope: &Envelope,
  111. loaded: &LoadedState,
  112. ) -> Result<kuatia_core::Plan, LedgerError> {
  113. let input = PlanInput {
  114. envelope,
  115. consumed_postings: &loaded.consumed_postings,
  116. accounts: &loaded.accounts,
  117. balances: &loaded.balances,
  118. book: loaded.book.as_ref(),
  119. };
  120. Ok(validate_and_plan(input)?)
  121. }
  122. // -----------------------------------------------------------------------
  123. // Resolve: Transfer (intent) -> Envelope (concrete postings)
  124. // -----------------------------------------------------------------------
  125. /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
  126. /// postings for each movement and computing change.
  127. ///
  128. /// The decision is pure ([`kuatia_core::draft_movements`] +
  129. /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
  130. /// functions need. Pass 1 aggregates net debits and tells us which postings
  131. /// to load and which accounts permit overdraft; pass 2 selects postings,
  132. /// computes change, and covers any overdraft shortfall.
  133. #[instrument(skip(self, transfer), name = "ledger.resolve")]
  134. pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
  135. let draft = draft_movements(transfer)?;
  136. // Load the active postings for each debit, and note which debit accounts
  137. // permit overdraft. A deposit nets to zero on the system account, so it
  138. // produces no debit and loads nothing here.
  139. let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
  140. let mut overdraft_allowed: HashSet<AccountId> = HashSet::new();
  141. let mut checked: HashSet<AccountId> = HashSet::new();
  142. for debit in &draft.debits {
  143. let postings = self
  144. .store
  145. .get_postings_by_account(
  146. debit.account.id,
  147. Some(debit.account.sub),
  148. Some(&debit.asset),
  149. PostingFilter::Active,
  150. )
  151. .await?;
  152. available.insert((debit.account, debit.asset), postings);
  153. if checked.insert(debit.account)
  154. && !self
  155. .store
  156. .get_account(&debit.account)
  157. .await?
  158. .forbids_overdraft()
  159. {
  160. overdraft_allowed.insert(debit.account);
  161. }
  162. }
  163. let mut envelope = resolve_envelope(ResolveInput {
  164. transfer,
  165. draft,
  166. available: &available,
  167. overdraft_allowed: &overdraft_allowed,
  168. })?;
  169. // Resolve account snapshots for optimistic concurrency
  170. let ids = envelope.referenced_accounts();
  171. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  172. Ok(envelope)
  173. }
  174. // -----------------------------------------------------------------------
  175. // Commit: every commit is the envelope saga (reserve -> finalize; finalize re-validates)
  176. // -----------------------------------------------------------------------
  177. /// Commit a [`Transfer`] intent. Resolves it into a concrete envelope, then
  178. /// drives the envelope saga. Resolution is read-only, so a crash before the
  179. /// saga's write-ahead record leaves no partial state.
  180. #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
  181. pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
  182. let envelope = self.resolve(&transfer).await?;
  183. self.commit_envelope(envelope).await
  184. }
  185. /// Commit a pre-resolved [`Envelope`] through the saga pipeline (reserve ->
  186. /// validate -> finalize). This is the single commit path; `commit()` and
  187. /// `reverse()` both funnel through it.
  188. ///
  189. /// Before running, the saga (envelope + reservation) is persisted as a
  190. /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
  191. /// record is deleted once the saga reaches a terminal state. The commit is
  192. /// idempotent on the content-addressed transfer id.
  193. #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
  194. pub async fn commit_envelope(
  195. self: &Arc<Self>,
  196. mut envelope: Envelope,
  197. ) -> Result<Receipt, LedgerError> {
  198. if envelope.account_snapshots().is_empty() {
  199. let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  200. ids.sort();
  201. ids.dedup();
  202. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  203. }
  204. // Idempotency: an already-committed transfer returns its receipt.
  205. let tid = envelope_id(&envelope);
  206. if let Some(record) = self.store.get_transfer(&tid).await? {
  207. return Ok(record.receipt);
  208. }
  209. // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
  210. // mutation. The finalize step bumps the phase to Finalizing.
  211. let reservation = kuatia_core::ReservationId::default();
  212. let saga_id = reservation.0;
  213. self.save_pending(&envelope, reservation, SagaPhase::Reserving)
  214. .await?;
  215. let result = self.drive_envelope_saga(envelope, reservation).await;
  216. // Delete the pending record only when it is safe: on success, or on a
  217. // failure that never reached finalize (phase still Reserving → the saga's
  218. // compensation released our reservation, nothing of ours was applied). If
  219. // finalize started (Finalizing) and failed, keep it so `recover()` rolls
  220. // the half-applied commit forward.
  221. let safe_to_delete = match &result {
  222. Ok(_) => true,
  223. Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
  224. };
  225. if safe_to_delete {
  226. self.store.delete_saga(&saga_id).await?;
  227. }
  228. result
  229. }
  230. /// Build and run the envelope saga (reserve → finalize) to a terminal
  231. /// outcome, returning the resulting receipt.
  232. async fn drive_envelope_saga(
  233. self: &Arc<Self>,
  234. envelope: Envelope,
  235. reservation: kuatia_core::ReservationId,
  236. ) -> Result<Receipt, LedgerError> {
  237. let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
  238. reserve: ReserveInput,
  239. finalize: FinalizeInput,
  240. });
  241. let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
  242. let execution = saga.build(ctx);
  243. match execution.start().await {
  244. ExecutionResult::Completed(e) => {
  245. let ctx = e.into_context();
  246. ctx.receipts.last().cloned().ok_or_else(|| {
  247. LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
  248. })
  249. }
  250. // The saga's error type is `LedgerError`, so a validation / overdraft
  251. // / frozen failure detected during commit reaches the caller as the
  252. // real typed variant instead of a stringified internal fault.
  253. ExecutionResult::Failed(_, err) => Err(err),
  254. ExecutionResult::CompensationFailed {
  255. original_error,
  256. compensation_error,
  257. ..
  258. } => Err(LedgerError::CompensationFailed {
  259. original: Box::new(original_error),
  260. compensation: Box::new(compensation_error),
  261. }),
  262. ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
  263. "saga paused unexpectedly".into(),
  264. ))),
  265. }
  266. }
  267. /// Complete every pending saga left by a crash. Call on startup; returns how
  268. /// many were processed.
  269. ///
  270. /// Recovery branches on the persisted phase. A `Reserving` saga had not
  271. /// necessarily validated, so it is re-run through the real saga (which
  272. /// re-reserves and **re-validates** — aborting cleanly if the postings were
  273. /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
  274. /// validated and owns its postings, so it is rolled forward through the
  275. /// verified `finalize_envelope`. Either way the record is removed only once
  276. /// the work is committed or safely abandoned.
  277. #[instrument(skip(self), name = "ledger.recover")]
  278. pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
  279. let pending = self.store.list_pending_sagas().await?;
  280. let count = pending.len();
  281. for (saga_id, blob) in pending {
  282. let PendingSaga {
  283. envelope,
  284. reservation,
  285. phase,
  286. } = serde_json::from_slice(&blob)
  287. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  288. // The transfer record is durable, but a full commit is more than the
  289. // transfer row: it also includes the committed event, appended *after*
  290. // store_transfer. A crash in that window leaves the record present yet
  291. // the event missing, so repair the whole end-state (idempotent) before
  292. // clearing the pending record.
  293. let tid = envelope_id(&envelope);
  294. if self.store.get_transfer(&tid).await?.is_some() {
  295. self.append_committed_event(tid).await?;
  296. self.store.delete_saga(&saga_id).await?;
  297. continue;
  298. }
  299. match phase {
  300. SagaPhase::Finalizing => {
  301. // Validation passed and the postings are ours; roll forward.
  302. // Keep the record if completion fails so a later run retries.
  303. if self.finalize_envelope(&envelope, reservation).await.is_ok() {
  304. self.store.delete_saga(&saga_id).await?;
  305. }
  306. }
  307. SagaPhase::Reserving => {
  308. // Re-run the validating saga. On failure, delete only if it did
  309. // not reach finalize (clean abort); otherwise keep for next run.
  310. let result = self.drive_envelope_saga(envelope, reservation).await;
  311. let safe_to_delete = result.is_ok()
  312. || self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing);
  313. if safe_to_delete {
  314. self.store.delete_saga(&saga_id).await?;
  315. }
  316. }
  317. }
  318. }
  319. Ok(count)
  320. }
  321. /// Idempotently finalize `envelope` to its committed state, **verifying every
  322. /// step's end-state**. Used by the saga's finalize step and by recovery.
  323. ///
  324. /// When the consumed postings are still reserved it re-validates against
  325. /// current state (the last-step floor / freeze-close guard) and then marks
  326. /// the saga `Finalizing` (the point of no return). Once any consumed posting
  327. /// is already spent — a prior attempt or recovery passed that point — it
  328. /// rolls forward without re-validating. It never creates or stores anything
  329. /// unless **all** consumed postings are confirmed spent, which is the
  330. /// double-spend guard.
  331. pub(crate) async fn finalize_envelope(
  332. &self,
  333. envelope: &Envelope,
  334. reservation: kuatia_core::ReservationId,
  335. ) -> Result<Receipt, LedgerError> {
  336. let tid = envelope_id(envelope);
  337. if let Some(record) = self.store.get_transfer(&tid).await? {
  338. // The transfer record is durable, but a crash (or a retried finalize)
  339. // can land between store_transfer and the event append below. The
  340. // committed end-state includes the event, so ensure it before
  341. // returning — `append_committed_event` is idempotent.
  342. self.append_committed_event(tid).await?;
  343. return Ok(record.receipt); // already committed
  344. }
  345. let consumes = envelope.consumes();
  346. // Read consumed postings (immutable rows, kept for owner indexing) and
  347. // their derived states.
  348. let consumed = if consumes.is_empty() {
  349. Vec::new()
  350. } else {
  351. self.store.get_postings(consumes).await?
  352. };
  353. let states = if consumes.is_empty() {
  354. Vec::new()
  355. } else {
  356. self.store.get_posting_states(consumes).await?
  357. };
  358. let past_no_return = states.contains(&PostingState::Spent);
  359. // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
  360. // against current state, but only while it is still safe (validation
  361. // rejects a consumed posting that is no longer live).
  362. if !past_no_return {
  363. let loaded = self.load(envelope).await?;
  364. self.plan(envelope, &loaded)?;
  365. }
  366. // Point of no return: record Finalizing before any posting is consumed.
  367. self.save_pending(envelope, reservation, SagaPhase::Finalizing)
  368. .await?;
  369. // Consume our reserved postings (remove from the reserved index → spent),
  370. // then assert ALL consumed postings are spent. This is the double-spend
  371. // guard: `deactivate_postings(Some(rid))` only removes rows we reserved,
  372. // so any consumed id still active or reserved by another saga leaves the
  373. // "all spent" check failing.
  374. let spent = self
  375. .store
  376. .deactivate_postings(consumes, Some(reservation))
  377. .await?;
  378. verify_postings(
  379. self.store.as_ref(),
  380. consumes,
  381. spent,
  382. |s| *s == PostingState::Spent,
  383. "finalize: consume reserved postings",
  384. )
  385. .await?;
  386. // Created postings, derived deterministically from the envelope.
  387. let created: Vec<Posting> = envelope
  388. .creates()
  389. .iter()
  390. .enumerate()
  391. .map(|(i, np)| {
  392. Posting::new(
  393. PostingId {
  394. transfer: tid,
  395. index: i as u16,
  396. },
  397. np.owner,
  398. np.asset,
  399. np.value,
  400. )
  401. })
  402. .collect();
  403. let inserted = self.store.insert_postings(&created).await?;
  404. let created_ids: Vec<PostingId> = created.iter().map(|p| p.id).collect();
  405. verify_postings(
  406. self.store.as_ref(),
  407. &created_ids,
  408. inserted,
  409. |s| *s != PostingState::Missing,
  410. "finalize: insert created postings",
  411. )
  412. .await?;
  413. // Index both created and consumed owners.
  414. let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
  415. involved.extend(consumed.iter().map(|p| p.owner));
  416. involved.sort();
  417. involved.dedup();
  418. let receipt = Receipt { transfer_id: tid };
  419. let stored = self
  420. .store
  421. .store_transfer(
  422. EnvelopeRecord {
  423. envelope: envelope.clone(),
  424. receipt: receipt.clone(),
  425. created_at: now_millis()?,
  426. },
  427. &involved,
  428. )
  429. .await?;
  430. apply_and_verify(stored, 1, "finalize: store transfer record", || async {
  431. Ok(self.store.get_transfer(&tid).await?.is_some())
  432. })
  433. .await?;
  434. self.append_committed_event(tid).await?;
  435. Ok(receipt)
  436. }
  437. /// Idempotently append the `TransferCommitted` event for `tid`.
  438. ///
  439. /// The event append is the final finalize step, *after* `store_transfer`, so a
  440. /// crash in that window leaves a stored transfer with no event. Recovery and a
  441. /// retried finalize both call this to repair the committed end-state.
  442. /// `append_event` dedups on the transfer id, so calling it more than once for
  443. /// the same transfer is a no-op.
  444. async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
  445. self.store
  446. .append_event(&LedgerEvent {
  447. seq: 0,
  448. timestamp: now_millis()?,
  449. kind: LedgerEventKind::TransferCommitted { transfer_id: tid },
  450. })
  451. .await?;
  452. Ok(())
  453. }
  454. /// Persist the write-ahead pending-saga record (upsert on the reservation id).
  455. async fn save_pending(
  456. &self,
  457. envelope: &Envelope,
  458. reservation: kuatia_core::ReservationId,
  459. phase: SagaPhase,
  460. ) -> Result<(), LedgerError> {
  461. let blob = serde_json::to_vec(&PendingSaga {
  462. envelope: envelope.clone(),
  463. reservation,
  464. phase,
  465. })
  466. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  467. self.store.save_saga(&reservation.0, blob).await?;
  468. Ok(())
  469. }
  470. /// Read the persisted phase of a pending saga, if it still exists.
  471. async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
  472. for (id, blob) in self.store.list_pending_sagas().await? {
  473. if id == saga_id {
  474. let pending: PendingSaga = serde_json::from_slice(&blob)
  475. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  476. return Ok(Some(pending.phase));
  477. }
  478. }
  479. Ok(None)
  480. }
  481. // -----------------------------------------------------------------------
  482. // Reverse
  483. // -----------------------------------------------------------------------
  484. /// Create and commit a reversal envelope for the given envelope id.
  485. #[instrument(skip(self), name = "ledger.reverse")]
  486. pub async fn reverse(self: &Arc<Self>, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
  487. let record = self
  488. .store
  489. .get_transfer(id)
  490. .await?
  491. .ok_or(LedgerError::TransferNotFound(*id))?;
  492. let original = &record.envelope;
  493. let created_posting_ids: Vec<PostingId> = original
  494. .creates()
  495. .iter()
  496. .enumerate()
  497. .map(|(i, _)| PostingId {
  498. transfer: record.receipt.transfer_id,
  499. index: i as u16,
  500. })
  501. .collect();
  502. let original_consumed = if original.consumes().is_empty() {
  503. vec![]
  504. } else {
  505. self.store.get_postings(original.consumes()).await?
  506. };
  507. let new_postings: Vec<NewPosting> = original_consumed
  508. .iter()
  509. .map(|p| NewPosting {
  510. owner: p.owner,
  511. asset: p.asset,
  512. value: p.value,
  513. payer: None,
  514. })
  515. .collect();
  516. let reverse_envelope = EnvelopeBuilder::new()
  517. .consumes(created_posting_ids)
  518. .creates(new_postings)
  519. .book(original.book())
  520. .metadata(original.metadata().clone())
  521. .build();
  522. self.commit_envelope(reverse_envelope).await
  523. }
  524. // -----------------------------------------------------------------------
  525. // Internal: resolve account snapshots
  526. // -----------------------------------------------------------------------
  527. async fn resolve_snapshots(
  528. &self,
  529. ids: &[AccountId],
  530. ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
  531. let accounts = self.store.get_accounts(ids).await?;
  532. Ok(accounts.iter().map(account_snapshot_id).collect())
  533. }
  534. }
  535. #[cfg(test)]
  536. mod recovery_tests {
  537. use super::*;
  538. use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder};
  539. use kuatia_storage::mem_store::InMemoryStore;
  540. use std::collections::BTreeMap;
  541. fn acct(id: i64, flags: AccountFlags) -> Account {
  542. Account {
  543. id: AccountId::new(id),
  544. version: 1,
  545. flags,
  546. book: kuatia_core::BookId(0),
  547. metadata: BTreeMap::new(),
  548. }
  549. }
  550. async fn funded_ledger() -> Arc<Ledger> {
  551. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  552. for (id, p) in [
  553. (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  554. (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  555. (3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  556. (99, AccountFlags::empty()),
  557. ] {
  558. ledger.store().create_account(acct(id, p)).await.unwrap();
  559. }
  560. let deposit = TransferBuilder::new()
  561. .deposit(
  562. AccountId::new(1),
  563. AssetId::new(1),
  564. Cent::from(100),
  565. AccountId::new(99),
  566. )
  567. .unwrap()
  568. .build();
  569. ledger.commit(deposit).await.unwrap();
  570. ledger
  571. }
  572. fn pay_transfer() -> Transfer {
  573. TransferBuilder::new()
  574. .pay(
  575. AccountId::new(1),
  576. AccountId::new(2),
  577. AssetId::new(1),
  578. Cent::from(40),
  579. )
  580. .build()
  581. }
  582. async fn save_pending(
  583. ledger: &Arc<Ledger>,
  584. envelope: &Envelope,
  585. rid: ReservationId,
  586. phase: SagaPhase,
  587. ) {
  588. let blob = serde_json::to_vec(&PendingSaga {
  589. envelope: envelope.clone(),
  590. reservation: rid,
  591. phase,
  592. })
  593. .unwrap();
  594. ledger.store().save_saga(&rid.0, blob).await.unwrap();
  595. }
  596. /// A commit interrupted right after its write-ahead record (phase Reserving,
  597. /// before any step) is re-run and completed by `recover()`.
  598. #[tokio::test]
  599. async fn recover_redrives_reserving_saga() {
  600. let ledger = funded_ledger().await;
  601. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  602. let rid = ReservationId::default();
  603. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  604. assert_eq!(ledger.recover().await.unwrap(), 1);
  605. assert_eq!(
  606. ledger
  607. .balance(&AccountId::new(2), &AssetId::new(1))
  608. .await
  609. .unwrap(),
  610. Cent::from(40)
  611. );
  612. assert_eq!(
  613. ledger
  614. .balance(&AccountId::new(1), &AssetId::new(1))
  615. .await
  616. .unwrap(),
  617. Cent::from(60)
  618. );
  619. assert!(
  620. ledger
  621. .store()
  622. .list_pending_sagas()
  623. .await
  624. .unwrap()
  625. .is_empty()
  626. );
  627. }
  628. /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
  629. /// is already spent) is rolled forward by `recover()`.
  630. #[tokio::test]
  631. async fn recover_completes_partial_finalize() {
  632. let ledger = funded_ledger().await;
  633. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  634. let rid = ReservationId::default();
  635. // Run the commit halfway: reserve + deactivate the consumed posting.
  636. let consumes = envelope.consumes().to_vec();
  637. ledger
  638. .store()
  639. .reserve_postings(&consumes, rid)
  640. .await
  641. .unwrap();
  642. assert_eq!(
  643. ledger
  644. .store()
  645. .deactivate_postings(&consumes, Some(rid))
  646. .await
  647. .unwrap(),
  648. 1
  649. );
  650. save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
  651. assert_eq!(ledger.recover().await.unwrap(), 1);
  652. assert_eq!(
  653. ledger
  654. .balance(&AccountId::new(2), &AssetId::new(1))
  655. .await
  656. .unwrap(),
  657. Cent::from(40)
  658. );
  659. assert_eq!(
  660. ledger
  661. .balance(&AccountId::new(1), &AssetId::new(1))
  662. .await
  663. .unwrap(),
  664. Cent::from(60)
  665. );
  666. assert!(
  667. ledger
  668. .store()
  669. .list_pending_sagas()
  670. .await
  671. .unwrap()
  672. .is_empty()
  673. );
  674. }
  675. /// A commit that crashed *after* `store_transfer` but *before* the committed
  676. /// event was appended (phase Finalizing, transfer row present, event missing)
  677. /// is repaired by `recover()`: the full end-state includes the event, so
  678. /// recovery appends it (idempotently) instead of treating the transfer row as
  679. /// proof of a complete commit.
  680. #[tokio::test]
  681. async fn recover_appends_missing_committed_event() {
  682. let ledger = funded_ledger().await;
  683. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  684. let tid = envelope_id(&envelope);
  685. let rid = ReservationId::default();
  686. // Replay finalize by hand up to and including store_transfer, stopping
  687. // short of the event append — exactly the crash window.
  688. let consumes = envelope.consumes().to_vec();
  689. ledger
  690. .store()
  691. .reserve_postings(&consumes, rid)
  692. .await
  693. .unwrap();
  694. ledger
  695. .store()
  696. .deactivate_postings(&consumes, Some(rid))
  697. .await
  698. .unwrap();
  699. let created: Vec<Posting> = envelope
  700. .creates()
  701. .iter()
  702. .enumerate()
  703. .map(|(i, np)| {
  704. Posting::new(
  705. PostingId {
  706. transfer: tid,
  707. index: i as u16,
  708. },
  709. np.owner,
  710. np.asset,
  711. np.value,
  712. )
  713. })
  714. .collect();
  715. ledger.store().insert_postings(&created).await.unwrap();
  716. let consumed = ledger.store().get_postings(&consumes).await.unwrap();
  717. let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
  718. involved.extend(consumed.iter().map(|p| p.owner));
  719. involved.sort();
  720. involved.dedup();
  721. ledger
  722. .store()
  723. .store_transfer(
  724. EnvelopeRecord {
  725. envelope: envelope.clone(),
  726. receipt: Receipt { transfer_id: tid },
  727. created_at: 0,
  728. },
  729. &involved,
  730. )
  731. .await
  732. .unwrap();
  733. save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
  734. // Precondition: the transfer is stored, but no committed event exists yet.
  735. let committed = |evs: &[LedgerEvent]| {
  736. evs.iter().any(|e| {
  737. matches!(
  738. e.kind,
  739. LedgerEventKind::TransferCommitted { transfer_id } if transfer_id == tid
  740. )
  741. })
  742. };
  743. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_some());
  744. assert!(!committed(&ledger.get_events_since(0, 1000).await.unwrap()));
  745. assert_eq!(ledger.recover().await.unwrap(), 1);
  746. // The missing event is repaired and the pending record cleared.
  747. assert!(committed(&ledger.get_events_since(0, 1000).await.unwrap()));
  748. assert!(
  749. ledger
  750. .store()
  751. .list_pending_sagas()
  752. .await
  753. .unwrap()
  754. .is_empty()
  755. );
  756. }
  757. /// Recovery of a `Reserving` saga re-validates against current state: if an
  758. /// account was frozen after the write-ahead record, the commit is abandoned —
  759. /// no postings move, the reservation is released, and the record is cleared.
  760. #[tokio::test]
  761. async fn recover_revalidates_and_aborts_when_account_frozen() {
  762. let ledger = funded_ledger().await;
  763. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  764. let tid = envelope_id(&envelope);
  765. let rid = ReservationId::default();
  766. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  767. // A freeze lands before recovery runs.
  768. ledger.freeze(&AccountId::new(1)).await.unwrap();
  769. assert_eq!(ledger.recover().await.unwrap(), 1);
  770. // Nothing committed; balances unchanged; reservation released.
  771. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
  772. assert_eq!(
  773. ledger
  774. .balance(&AccountId::new(1), &AssetId::new(1))
  775. .await
  776. .unwrap(),
  777. Cent::from(100)
  778. );
  779. assert_eq!(
  780. ledger
  781. .balance(&AccountId::new(2), &AssetId::new(1))
  782. .await
  783. .unwrap(),
  784. Cent::ZERO
  785. );
  786. let active = ledger
  787. .store()
  788. .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::Active)
  789. .await
  790. .unwrap();
  791. assert_eq!(active.len(), 1); // back to Active
  792. assert!(
  793. ledger
  794. .store()
  795. .list_pending_sagas()
  796. .await
  797. .unwrap()
  798. .is_empty()
  799. );
  800. }
  801. /// Recovery cannot double-spend: if the consumed posting was taken by another
  802. /// transfer while the saga was pending, recovery aborts without creating or
  803. /// storing anything.
  804. #[tokio::test]
  805. async fn recover_does_not_double_spend_a_taken_posting() {
  806. let ledger = funded_ledger().await;
  807. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  808. let tid = envelope_id(&envelope);
  809. let rid = ReservationId::default();
  810. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  811. // Another transfer consumes account 1's posting and commits.
  812. let steal = TransferBuilder::new()
  813. .pay(
  814. AccountId::new(1),
  815. AccountId::new(3),
  816. AssetId::new(1),
  817. Cent::from(50),
  818. )
  819. .build();
  820. ledger.commit(steal).await.unwrap();
  821. assert_eq!(ledger.recover().await.unwrap(), 1);
  822. // Our envelope never committed; only the stealing transfer applied.
  823. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
  824. assert_eq!(
  825. ledger
  826. .balance(&AccountId::new(1), &AssetId::new(1))
  827. .await
  828. .unwrap(),
  829. Cent::from(50)
  830. );
  831. assert_eq!(
  832. ledger
  833. .balance(&AccountId::new(3), &AssetId::new(1))
  834. .await
  835. .unwrap(),
  836. Cent::from(50)
  837. );
  838. assert_eq!(
  839. ledger
  840. .balance(&AccountId::new(2), &AssetId::new(1))
  841. .await
  842. .unwrap(),
  843. Cent::ZERO
  844. );
  845. assert!(
  846. ledger
  847. .store()
  848. .list_pending_sagas()
  849. .await
  850. .unwrap()
  851. .is_empty()
  852. );
  853. }
  854. }