commit.rs 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210
  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. /// Write-ahead record for an in-flight account-version transition
  47. /// (freeze/unfreeze/close). The transition appends a new account version and then
  48. /// its lifecycle event; a crash between the two leaves a version bump with no
  49. /// event. Persisting this before either write lets [`Ledger::recover`] roll the
  50. /// transition forward, re-appending the (idempotent) event.
  51. #[derive(serde::Serialize, serde::Deserialize)]
  52. pub(super) struct PendingTransition {
  53. /// The next account version to append: version already bumped, flag flipped.
  54. pub next: kuatia_core::Account,
  55. /// The lifecycle event paired with this version bump. It carries the target
  56. /// version, so re-appending it on recovery dedups to the original.
  57. pub event: LedgerEventKind,
  58. }
  59. /// The two kinds of write-ahead record the [`SagaStore`](kuatia_storage::store::SagaStore)
  60. /// holds, tagged so [`Ledger::recover`] can tell an envelope commit saga from an
  61. /// account transition and complete each through its own path.
  62. #[derive(serde::Serialize, serde::Deserialize)]
  63. enum PendingRecord {
  64. /// A two-step envelope commit saga (reserve → finalize).
  65. Envelope(PendingSaga),
  66. /// A single account-version transition (append version + lifecycle event).
  67. Transition(PendingTransition),
  68. }
  69. /// State loaded in phase 1, passed to the pure validation in phase 2.
  70. pub struct LoadedState {
  71. /// Postings being consumed by the envelope.
  72. pub consumed_postings: Vec<Posting>,
  73. /// Accounts referenced by the envelope.
  74. pub accounts: HashMap<AccountId, kuatia_core::Account>,
  75. /// Current balances for all referenced (account, asset) pairs.
  76. pub balances: HashMap<(AccountId, AssetId), Cent>,
  77. /// The book gating this transfer, if one is loaded (`None` = unrestricted default).
  78. pub book: Option<Book>,
  79. }
  80. impl Ledger {
  81. // -----------------------------------------------------------------------
  82. // Three-piece API: load -> plan -> apply
  83. // -----------------------------------------------------------------------
  84. /// Phase 1: load all state needed for validation.
  85. #[instrument(skip(self, envelope), name = "ledger.load")]
  86. pub async fn load(&self, envelope: &Envelope) -> Result<LoadedState, LedgerError> {
  87. let consumed_postings = if envelope.consumes().is_empty() {
  88. vec![]
  89. } else {
  90. self.store.get_postings(envelope.consumes()).await?
  91. };
  92. let mut account_ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  93. for p in &consumed_postings {
  94. account_ids.push(p.owner);
  95. }
  96. account_ids.sort();
  97. account_ids.dedup();
  98. let account_list = self.store.get_accounts(&account_ids).await?;
  99. let accounts: HashMap<AccountId, _> = account_list.into_iter().map(|a| (a.id, a)).collect();
  100. let mut balance_keys: Vec<(AccountId, AssetId)> = Vec::new();
  101. for p in &consumed_postings {
  102. balance_keys.push((p.owner, p.asset));
  103. }
  104. for np in envelope.creates() {
  105. balance_keys.push((np.owner, np.asset));
  106. }
  107. balance_keys.sort();
  108. balance_keys.dedup();
  109. let mut balances = HashMap::new();
  110. for (account_id, asset_id) in &balance_keys {
  111. let bal = self.compute_balance(account_id, asset_id).await?;
  112. balances.insert((*account_id, *asset_id), bal);
  113. }
  114. // Load the gating book. A missing named (non-default) book is an error;
  115. // a missing default book means "unrestricted" (no policy to enforce).
  116. let book_id = envelope.book();
  117. let book = match self.store.get_book(&book_id).await {
  118. Ok(b) => Some(b),
  119. Err(StoreError::NotFound(_)) if book_id == DEFAULT_BOOK => None,
  120. Err(StoreError::NotFound(_)) => return Err(LedgerError::BookNotFound(book_id)),
  121. Err(e) => return Err(e.into()),
  122. };
  123. Ok(LoadedState {
  124. consumed_postings,
  125. accounts,
  126. balances,
  127. book,
  128. })
  129. }
  130. /// Phase 2: run pure validation and produce a plan.
  131. pub fn plan(
  132. &self,
  133. envelope: &Envelope,
  134. loaded: &LoadedState,
  135. ) -> Result<kuatia_core::Plan, LedgerError> {
  136. let input = PlanInput {
  137. envelope,
  138. consumed_postings: &loaded.consumed_postings,
  139. accounts: &loaded.accounts,
  140. balances: &loaded.balances,
  141. book: loaded.book.as_ref(),
  142. };
  143. Ok(validate_and_plan(input)?)
  144. }
  145. // -----------------------------------------------------------------------
  146. // Resolve: Transfer (intent) -> Envelope (concrete postings)
  147. // -----------------------------------------------------------------------
  148. /// Convert a [`Transfer`] intent into a concrete [`Envelope`] by selecting
  149. /// postings for each movement and computing change.
  150. ///
  151. /// The decision is pure ([`kuatia_core::draft_movements`] +
  152. /// [`kuatia_core::resolve_envelope`]); this method only loads the state those
  153. /// functions need. Pass 1 aggregates net debits and tells us which postings
  154. /// to load and which accounts permit overdraft; pass 2 selects postings,
  155. /// computes change, and covers any overdraft shortfall.
  156. #[instrument(skip(self, transfer), name = "ledger.resolve")]
  157. pub async fn resolve(&self, transfer: &Transfer) -> Result<Envelope, LedgerError> {
  158. let draft = draft_movements(transfer)?;
  159. // Load the active postings for each debit, and note which debit accounts
  160. // permit overdraft. A deposit nets to zero on the system account, so it
  161. // produces no debit and loads nothing here.
  162. let mut available: HashMap<(AccountId, AssetId), Vec<Posting>> = HashMap::new();
  163. let mut overdraft_allowed: HashSet<AccountId> = HashSet::new();
  164. let mut checked: HashSet<AccountId> = HashSet::new();
  165. for debit in &draft.debits {
  166. let postings = self
  167. .store
  168. .get_postings_by_account(
  169. debit.account.id,
  170. Some(debit.account.sub),
  171. Some(&debit.asset),
  172. PostingFilter::Active,
  173. )
  174. .await?;
  175. available.insert((debit.account, debit.asset), postings);
  176. if checked.insert(debit.account)
  177. && !self
  178. .store
  179. .get_account(&debit.account)
  180. .await?
  181. .forbids_overdraft()
  182. {
  183. overdraft_allowed.insert(debit.account);
  184. }
  185. }
  186. let mut envelope = resolve_envelope(ResolveInput {
  187. transfer,
  188. draft,
  189. available: &available,
  190. overdraft_allowed: &overdraft_allowed,
  191. })?;
  192. // Resolve account snapshots for optimistic concurrency
  193. let ids = envelope.referenced_accounts();
  194. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  195. Ok(envelope)
  196. }
  197. // -----------------------------------------------------------------------
  198. // Commit: every commit is the envelope saga (reserve -> finalize; finalize re-validates)
  199. // -----------------------------------------------------------------------
  200. /// Commit a [`Transfer`] intent. Resolves it into a concrete envelope, then
  201. /// drives the envelope saga. Resolution is read-only, so a crash before the
  202. /// saga's write-ahead record leaves no partial state.
  203. #[instrument(skip(self, transfer), fields(book = transfer.book.0), name = "ledger.commit")]
  204. pub async fn commit(self: &Arc<Self>, transfer: Transfer) -> Result<Receipt, LedgerError> {
  205. let envelope = self.resolve(&transfer).await?;
  206. self.commit_envelope(envelope).await
  207. }
  208. /// Commit a pre-resolved [`Envelope`] through the saga pipeline (reserve ->
  209. /// validate -> finalize). This is the single commit path; `commit()` and
  210. /// `reverse()` both funnel through it.
  211. ///
  212. /// Before running, the saga (envelope + reservation) is persisted as a
  213. /// pending record so a crash mid-commit is completed by [`recover`](Self::recover). The
  214. /// record is deleted once the saga reaches a terminal state. The commit is
  215. /// idempotent on the content-addressed transfer id.
  216. #[instrument(skip(self, envelope), name = "ledger.commit_envelope")]
  217. pub async fn commit_envelope(
  218. self: &Arc<Self>,
  219. mut envelope: Envelope,
  220. ) -> Result<Receipt, LedgerError> {
  221. if envelope.account_snapshots().is_empty() {
  222. let mut ids: Vec<AccountId> = envelope.creates().iter().map(|p| p.owner).collect();
  223. ids.sort();
  224. ids.dedup();
  225. envelope.set_account_snapshots(self.resolve_snapshots(&ids).await?);
  226. }
  227. // Idempotency: an already-committed transfer returns its receipt.
  228. let tid = envelope_id(&envelope);
  229. if let Some(record) = self.store.get_transfer(&tid).await? {
  230. return Ok(record.receipt);
  231. }
  232. // Write-ahead: persist {envelope, reservation, phase=Reserving} before any
  233. // mutation. The finalize step bumps the phase to Finalizing.
  234. let reservation = kuatia_core::ReservationId::default();
  235. let saga_id = reservation.0;
  236. self.save_pending(&envelope, reservation, SagaPhase::Reserving)
  237. .await?;
  238. // Commit does not touch the balance projection (ADR-0019): cache points
  239. // are appended lazily on read, once enough credits/debits have accrued.
  240. let result = self.drive_envelope_saga(envelope, reservation).await;
  241. // Delete the pending record only when it is safe: on success, or on a
  242. // failure that never reached finalize (phase still Reserving → the saga's
  243. // compensation released our reservation, nothing of ours was applied). If
  244. // finalize started (Finalizing) and failed, keep it so `recover()` rolls
  245. // the half-applied commit forward.
  246. let safe_to_delete = match &result {
  247. Ok(_) => true,
  248. Err(_) => self.read_pending_phase(saga_id).await? != Some(SagaPhase::Finalizing),
  249. };
  250. if safe_to_delete {
  251. self.store.delete_saga(&saga_id).await?;
  252. }
  253. result
  254. }
  255. /// Build and run the envelope saga (reserve → finalize) to a terminal
  256. /// outcome, returning the resulting receipt.
  257. async fn drive_envelope_saga(
  258. self: &Arc<Self>,
  259. envelope: Envelope,
  260. reservation: kuatia_core::ReservationId,
  261. ) -> Result<Receipt, LedgerError> {
  262. let saga = EnvelopeSaga::new(EnvelopeSagaInputs {
  263. reserve: ReserveInput,
  264. finalize: FinalizeInput,
  265. });
  266. let ctx = LedgerCtx::for_envelope(Arc::clone(self), envelope, reservation);
  267. let execution = saga.build(ctx);
  268. match execution.start().await {
  269. ExecutionResult::Completed(e) => {
  270. let ctx = e.into_context();
  271. ctx.receipts.last().cloned().ok_or_else(|| {
  272. LedgerError::Store(StoreError::Internal("saga completed but no receipt".into()))
  273. })
  274. }
  275. // The saga's error type is `LedgerError`, so a validation / overdraft
  276. // / frozen failure detected during commit reaches the caller as the
  277. // real typed variant instead of a stringified internal fault.
  278. ExecutionResult::Failed(_, err) => Err(err),
  279. ExecutionResult::CompensationFailed {
  280. original_error,
  281. compensation_error,
  282. ..
  283. } => Err(LedgerError::CompensationFailed {
  284. original: Box::new(original_error),
  285. compensation: Box::new(compensation_error),
  286. }),
  287. ExecutionResult::Paused(_) => Err(LedgerError::Store(StoreError::Internal(
  288. "saga paused unexpectedly".into(),
  289. ))),
  290. }
  291. }
  292. /// Complete every pending saga left by a crash. Call on startup; returns how
  293. /// many were processed.
  294. ///
  295. /// Recovery branches on the persisted phase. A `Reserving` saga had not
  296. /// necessarily validated, so it is re-run through the real saga (which
  297. /// re-reserves and **re-validates** — aborting cleanly if the postings were
  298. /// taken or an account was frozen meanwhile). A `Finalizing` saga had already
  299. /// validated and owns its postings, so it is rolled forward through the
  300. /// verified `finalize_envelope`. Either way the record is removed only once
  301. /// the work is committed or safely abandoned.
  302. #[instrument(skip(self), name = "ledger.recover")]
  303. pub async fn recover(self: &Arc<Self>) -> Result<usize, LedgerError> {
  304. let pending = self.store.list_pending_sagas().await?;
  305. let count = pending.len();
  306. for (saga_id, blob) in pending {
  307. let record: PendingRecord = serde_json::from_slice(&blob)
  308. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  309. match record {
  310. PendingRecord::Transition(PendingTransition { next, event }) => {
  311. // Roll the account transition forward: append the version if it
  312. // is not yet present, then (re-)append the idempotent event.
  313. // Both steps no-op when already applied, so this is safe to run
  314. // in any crash window.
  315. self.complete_transition(saga_id, next, event).await?;
  316. }
  317. PendingRecord::Envelope(PendingSaga {
  318. envelope,
  319. reservation,
  320. phase,
  321. }) => {
  322. // The transfer record is durable, but a full commit is more
  323. // than the transfer row: it also includes the committed event,
  324. // appended *after* store_transfer. A crash in that window
  325. // leaves the record present yet the event missing, so repair
  326. // the whole end-state (idempotent) before clearing the record.
  327. let tid = envelope_id(&envelope);
  328. if self.store.get_transfer(&tid).await?.is_some() {
  329. self.append_committed_event(tid).await?;
  330. self.store.delete_saga(&saga_id).await?;
  331. continue;
  332. }
  333. match phase {
  334. SagaPhase::Finalizing => {
  335. // Validation passed and the postings are ours; roll
  336. // forward. Keep the record if completion fails so a
  337. // later run retries.
  338. if self.finalize_envelope(&envelope, reservation).await.is_ok() {
  339. self.store.delete_saga(&saga_id).await?;
  340. }
  341. }
  342. SagaPhase::Reserving => {
  343. // Re-run the validating saga. On failure, delete only if
  344. // it did not reach finalize (clean abort); otherwise
  345. // keep for next run.
  346. let result = self.drive_envelope_saga(envelope, reservation).await;
  347. let safe_to_delete = result.is_ok()
  348. || self.read_pending_phase(saga_id).await?
  349. != Some(SagaPhase::Finalizing);
  350. if safe_to_delete {
  351. self.store.delete_saga(&saga_id).await?;
  352. }
  353. }
  354. }
  355. }
  356. }
  357. }
  358. Ok(count)
  359. }
  360. /// Idempotently finalize `envelope` to its committed state, **verifying every
  361. /// step's end-state**. Used by the saga's finalize step and by recovery.
  362. ///
  363. /// When the consumed postings are still reserved it re-validates against
  364. /// current state (the last-step floor / freeze-close guard) and then marks
  365. /// the saga `Finalizing` (the point of no return). Once any consumed posting
  366. /// is already spent — a prior attempt or recovery passed that point — it
  367. /// rolls forward without re-validating. It never creates or stores anything
  368. /// unless **all** consumed postings are confirmed spent, which is the
  369. /// double-spend guard.
  370. pub(crate) async fn finalize_envelope(
  371. &self,
  372. envelope: &Envelope,
  373. reservation: kuatia_core::ReservationId,
  374. ) -> Result<Receipt, LedgerError> {
  375. let tid = envelope_id(envelope);
  376. if let Some(record) = self.store.get_transfer(&tid).await? {
  377. // The transfer record is durable, but a crash (or a retried finalize)
  378. // can land between store_transfer and the event append below. The
  379. // committed end-state includes the event, so ensure it before
  380. // returning — `append_committed_event` is idempotent.
  381. self.append_committed_event(tid).await?;
  382. return Ok(record.receipt); // already committed
  383. }
  384. let consumes = envelope.consumes();
  385. // Read consumed postings (immutable rows, kept for owner indexing) and
  386. // their derived states.
  387. let consumed = if consumes.is_empty() {
  388. Vec::new()
  389. } else {
  390. self.store.get_postings(consumes).await?
  391. };
  392. let states = if consumes.is_empty() {
  393. Vec::new()
  394. } else {
  395. self.store.get_posting_states(consumes).await?
  396. };
  397. let past_no_return = states.contains(&PostingState::Spent);
  398. // Last-step boundary re-check: re-validate floor + freeze/close + snapshots
  399. // against current state, but only while it is still safe (validation
  400. // rejects a consumed posting that is no longer live).
  401. if !past_no_return {
  402. let loaded = self.load(envelope).await?;
  403. self.plan(envelope, &loaded)?;
  404. }
  405. // Point of no return: record Finalizing before any posting is consumed.
  406. self.save_pending(envelope, reservation, SagaPhase::Finalizing)
  407. .await?;
  408. // Consume our reserved postings (remove from the reserved index → spent),
  409. // then assert ALL consumed postings are spent. This is the double-spend
  410. // guard: `deactivate_postings(Some(rid))` only removes rows we reserved,
  411. // so any consumed id still active or reserved by another saga leaves the
  412. // "all spent" check failing.
  413. let spent = self
  414. .store
  415. .deactivate_postings(consumes, Some(reservation))
  416. .await?;
  417. verify_postings(
  418. self.store.as_ref(),
  419. consumes,
  420. spent,
  421. |s| *s == PostingState::Spent,
  422. "finalize: consume reserved postings",
  423. )
  424. .await?;
  425. // Created postings, derived deterministically from the envelope.
  426. let created: Vec<Posting> = envelope
  427. .creates()
  428. .iter()
  429. .enumerate()
  430. .map(|(i, np)| {
  431. Posting::new(
  432. PostingId {
  433. transfer: tid,
  434. index: i as u16,
  435. },
  436. np.owner,
  437. np.asset,
  438. np.value,
  439. )
  440. })
  441. .collect();
  442. let inserted = self.store.insert_postings(&created).await?;
  443. let created_ids: Vec<PostingId> = created.iter().map(|p| p.id).collect();
  444. verify_postings(
  445. self.store.as_ref(),
  446. &created_ids,
  447. inserted,
  448. |s| *s != PostingState::Missing,
  449. "finalize: insert created postings",
  450. )
  451. .await?;
  452. // Index both created and consumed owners.
  453. let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
  454. involved.extend(consumed.iter().map(|p| p.owner));
  455. involved.sort();
  456. involved.dedup();
  457. let receipt = Receipt { transfer_id: tid };
  458. let stored = self
  459. .store
  460. .store_transfer(
  461. EnvelopeRecord {
  462. envelope: envelope.clone(),
  463. receipt: receipt.clone(),
  464. created_at: now_millis()?,
  465. },
  466. &involved,
  467. )
  468. .await?;
  469. apply_and_verify(stored, 1, "finalize: store transfer record", || async {
  470. Ok(self.store.get_transfer(&tid).await?.is_some())
  471. })
  472. .await?;
  473. self.append_committed_event(tid).await?;
  474. Ok(receipt)
  475. }
  476. /// Idempotently append the `TransferCommitted` event for `tid`.
  477. ///
  478. /// The event append is the final finalize step, *after* `store_transfer`, so a
  479. /// crash in that window leaves a stored transfer with no event. Recovery and a
  480. /// retried finalize both call this to repair the committed end-state.
  481. /// `append_event` dedups on the transfer id, so calling it more than once for
  482. /// the same transfer is a no-op.
  483. async fn append_committed_event(&self, tid: EnvelopeId) -> Result<(), LedgerError> {
  484. self.store
  485. .append_event(&LedgerEvent {
  486. seq: 0,
  487. timestamp: now_millis()?,
  488. kind: LedgerEventKind::TransferCommitted { transfer_id: tid },
  489. })
  490. .await?;
  491. Ok(())
  492. }
  493. /// Persist the write-ahead pending-saga record (upsert on the reservation id).
  494. async fn save_pending(
  495. &self,
  496. envelope: &Envelope,
  497. reservation: kuatia_core::ReservationId,
  498. phase: SagaPhase,
  499. ) -> Result<(), LedgerError> {
  500. let blob = serde_json::to_vec(&PendingRecord::Envelope(PendingSaga {
  501. envelope: envelope.clone(),
  502. reservation,
  503. phase,
  504. }))
  505. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  506. self.store.save_saga(&reservation.0, blob).await?;
  507. Ok(())
  508. }
  509. /// Persist the write-ahead record for an account-version transition, keyed by
  510. /// a fresh unique id, and return that id so the caller can delete the record
  511. /// once the transition is complete. Shares the reservation-id generator so the
  512. /// key never collides with an in-flight commit saga's key.
  513. pub(super) async fn save_transition(
  514. &self,
  515. next: &kuatia_core::Account,
  516. event: &LedgerEventKind,
  517. ) -> Result<i64, LedgerError> {
  518. let saga_id = kuatia_core::ReservationId::default().0;
  519. let blob = serde_json::to_vec(&PendingRecord::Transition(PendingTransition {
  520. next: next.clone(),
  521. event: event.clone(),
  522. }))
  523. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  524. self.store.save_saga(&saga_id, blob).await?;
  525. Ok(saga_id)
  526. }
  527. /// Read the persisted phase of a pending *envelope* saga, if one exists under
  528. /// `saga_id`. A transition record (no phase) reads as `None`.
  529. async fn read_pending_phase(&self, saga_id: i64) -> Result<Option<SagaPhase>, LedgerError> {
  530. for (id, blob) in self.store.list_pending_sagas().await? {
  531. if id == saga_id {
  532. let record: PendingRecord = serde_json::from_slice(&blob)
  533. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  534. return Ok(match record {
  535. PendingRecord::Envelope(s) => Some(s.phase),
  536. PendingRecord::Transition(_) => None,
  537. });
  538. }
  539. }
  540. Ok(None)
  541. }
  542. // -----------------------------------------------------------------------
  543. // Reverse
  544. // -----------------------------------------------------------------------
  545. /// Create and commit a reversal envelope for the given envelope id.
  546. #[instrument(skip(self), name = "ledger.reverse")]
  547. pub async fn reverse(self: &Arc<Self>, id: &EnvelopeId) -> Result<Receipt, LedgerError> {
  548. let record = self
  549. .store
  550. .get_transfer(id)
  551. .await?
  552. .ok_or(LedgerError::TransferNotFound(*id))?;
  553. let original = &record.envelope;
  554. let created_posting_ids: Vec<PostingId> = original
  555. .creates()
  556. .iter()
  557. .enumerate()
  558. .map(|(i, _)| PostingId {
  559. transfer: record.receipt.transfer_id,
  560. index: i as u16,
  561. })
  562. .collect();
  563. let original_consumed = if original.consumes().is_empty() {
  564. vec![]
  565. } else {
  566. self.store.get_postings(original.consumes()).await?
  567. };
  568. let new_postings: Vec<NewPosting> = original_consumed
  569. .iter()
  570. .map(|p| NewPosting {
  571. owner: p.owner,
  572. asset: p.asset,
  573. value: p.value,
  574. payer: None,
  575. })
  576. .collect();
  577. let reverse_envelope = EnvelopeBuilder::new()
  578. .consumes(created_posting_ids)
  579. .creates(new_postings)
  580. .book(original.book())
  581. .metadata(original.metadata().clone())
  582. .build();
  583. self.commit_envelope(reverse_envelope).await
  584. }
  585. // -----------------------------------------------------------------------
  586. // Internal: resolve account snapshots
  587. // -----------------------------------------------------------------------
  588. async fn resolve_snapshots(
  589. &self,
  590. ids: &[AccountId],
  591. ) -> Result<Vec<AccountSnapshotId>, LedgerError> {
  592. let accounts = self.store.get_accounts(ids).await?;
  593. Ok(accounts.iter().map(account_snapshot_id).collect())
  594. }
  595. }
  596. #[cfg(test)]
  597. mod recovery_tests {
  598. use super::*;
  599. use kuatia_core::{Account, AccountFlags, ReservationId, TransferBuilder};
  600. use kuatia_storage::mem_store::InMemoryStore;
  601. use std::collections::BTreeMap;
  602. fn acct(id: i64, flags: AccountFlags) -> Account {
  603. Account {
  604. id: AccountId::new(id),
  605. version: 1,
  606. flags,
  607. book: kuatia_core::BookId(0),
  608. metadata: BTreeMap::new(),
  609. }
  610. }
  611. async fn funded_ledger() -> Arc<Ledger> {
  612. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  613. for (id, p) in [
  614. (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  615. (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  616. (3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  617. (99, AccountFlags::empty()),
  618. ] {
  619. ledger.store().create_account(acct(id, p)).await.unwrap();
  620. }
  621. let deposit = TransferBuilder::new()
  622. .deposit(
  623. AccountId::new(1),
  624. AssetId::new(1),
  625. Cent::from(100),
  626. AccountId::new(99),
  627. )
  628. .unwrap()
  629. .build();
  630. ledger.commit(deposit).await.unwrap();
  631. ledger
  632. }
  633. fn pay_transfer() -> Transfer {
  634. TransferBuilder::new()
  635. .pay(
  636. AccountId::new(1),
  637. AccountId::new(2),
  638. AssetId::new(1),
  639. Cent::from(40),
  640. )
  641. .build()
  642. }
  643. async fn save_pending(
  644. ledger: &Arc<Ledger>,
  645. envelope: &Envelope,
  646. rid: ReservationId,
  647. phase: SagaPhase,
  648. ) {
  649. let blob = serde_json::to_vec(&PendingRecord::Envelope(PendingSaga {
  650. envelope: envelope.clone(),
  651. reservation: rid,
  652. phase,
  653. }))
  654. .unwrap();
  655. ledger.store().save_saga(&rid.0, blob).await.unwrap();
  656. }
  657. /// A commit interrupted right after its write-ahead record (phase Reserving,
  658. /// before any step) is re-run and completed by `recover()`.
  659. #[tokio::test]
  660. async fn recover_redrives_reserving_saga() {
  661. let ledger = funded_ledger().await;
  662. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  663. let rid = ReservationId::default();
  664. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  665. assert_eq!(ledger.recover().await.unwrap(), 1);
  666. assert_eq!(
  667. ledger
  668. .balance(&AccountId::new(2), &AssetId::new(1))
  669. .await
  670. .unwrap(),
  671. Cent::from(40)
  672. );
  673. assert_eq!(
  674. ledger
  675. .balance(&AccountId::new(1), &AssetId::new(1))
  676. .await
  677. .unwrap(),
  678. Cent::from(60)
  679. );
  680. assert!(
  681. ledger
  682. .store()
  683. .list_pending_sagas()
  684. .await
  685. .unwrap()
  686. .is_empty()
  687. );
  688. }
  689. /// A commit that crashed mid-finalize (phase Finalizing; the consumed posting
  690. /// is already spent) is rolled forward by `recover()`.
  691. #[tokio::test]
  692. async fn recover_completes_partial_finalize() {
  693. let ledger = funded_ledger().await;
  694. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  695. let rid = ReservationId::default();
  696. // Run the commit halfway: reserve + deactivate the consumed posting.
  697. let consumes = envelope.consumes().to_vec();
  698. ledger
  699. .store()
  700. .reserve_postings(&consumes, rid)
  701. .await
  702. .unwrap();
  703. assert_eq!(
  704. ledger
  705. .store()
  706. .deactivate_postings(&consumes, Some(rid))
  707. .await
  708. .unwrap(),
  709. 1
  710. );
  711. save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
  712. assert_eq!(ledger.recover().await.unwrap(), 1);
  713. assert_eq!(
  714. ledger
  715. .balance(&AccountId::new(2), &AssetId::new(1))
  716. .await
  717. .unwrap(),
  718. Cent::from(40)
  719. );
  720. assert_eq!(
  721. ledger
  722. .balance(&AccountId::new(1), &AssetId::new(1))
  723. .await
  724. .unwrap(),
  725. Cent::from(60)
  726. );
  727. assert!(
  728. ledger
  729. .store()
  730. .list_pending_sagas()
  731. .await
  732. .unwrap()
  733. .is_empty()
  734. );
  735. }
  736. /// A commit that crashed *after* `store_transfer` but *before* the committed
  737. /// event was appended (phase Finalizing, transfer row present, event missing)
  738. /// is repaired by `recover()`: the full end-state includes the event, so
  739. /// recovery appends it (idempotently) instead of treating the transfer row as
  740. /// proof of a complete commit.
  741. #[tokio::test]
  742. async fn recover_appends_missing_committed_event() {
  743. let ledger = funded_ledger().await;
  744. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  745. let tid = envelope_id(&envelope);
  746. let rid = ReservationId::default();
  747. // Replay finalize by hand up to and including store_transfer, stopping
  748. // short of the event append — exactly the crash window.
  749. let consumes = envelope.consumes().to_vec();
  750. ledger
  751. .store()
  752. .reserve_postings(&consumes, rid)
  753. .await
  754. .unwrap();
  755. ledger
  756. .store()
  757. .deactivate_postings(&consumes, Some(rid))
  758. .await
  759. .unwrap();
  760. let created: Vec<Posting> = envelope
  761. .creates()
  762. .iter()
  763. .enumerate()
  764. .map(|(i, np)| {
  765. Posting::new(
  766. PostingId {
  767. transfer: tid,
  768. index: i as u16,
  769. },
  770. np.owner,
  771. np.asset,
  772. np.value,
  773. )
  774. })
  775. .collect();
  776. ledger.store().insert_postings(&created).await.unwrap();
  777. let consumed = ledger.store().get_postings(&consumes).await.unwrap();
  778. let mut involved: Vec<AccountId> = created.iter().map(|p| p.owner).collect();
  779. involved.extend(consumed.iter().map(|p| p.owner));
  780. involved.sort();
  781. involved.dedup();
  782. ledger
  783. .store()
  784. .store_transfer(
  785. EnvelopeRecord {
  786. envelope: envelope.clone(),
  787. receipt: Receipt { transfer_id: tid },
  788. created_at: 0,
  789. },
  790. &involved,
  791. )
  792. .await
  793. .unwrap();
  794. save_pending(&ledger, &envelope, rid, SagaPhase::Finalizing).await;
  795. // Precondition: the transfer is stored, but no committed event exists yet.
  796. let committed = |evs: &[LedgerEvent]| {
  797. evs.iter().any(|e| {
  798. matches!(
  799. e.kind,
  800. LedgerEventKind::TransferCommitted { transfer_id } if transfer_id == tid
  801. )
  802. })
  803. };
  804. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_some());
  805. assert!(!committed(&ledger.get_events_since(0, 1000).await.unwrap()));
  806. assert_eq!(ledger.recover().await.unwrap(), 1);
  807. // The missing event is repaired and the pending record cleared.
  808. assert!(committed(&ledger.get_events_since(0, 1000).await.unwrap()));
  809. assert!(
  810. ledger
  811. .store()
  812. .list_pending_sagas()
  813. .await
  814. .unwrap()
  815. .is_empty()
  816. );
  817. }
  818. /// Recovery of a `Reserving` saga re-validates against current state: if an
  819. /// account was frozen after the write-ahead record, the commit is abandoned —
  820. /// no postings move, the reservation is released, and the record is cleared.
  821. #[tokio::test]
  822. async fn recover_revalidates_and_aborts_when_account_frozen() {
  823. let ledger = funded_ledger().await;
  824. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  825. let tid = envelope_id(&envelope);
  826. let rid = ReservationId::default();
  827. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  828. // A freeze lands before recovery runs.
  829. ledger.freeze(&AccountId::new(1)).await.unwrap();
  830. assert_eq!(ledger.recover().await.unwrap(), 1);
  831. // Nothing committed; balances unchanged; reservation released.
  832. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
  833. assert_eq!(
  834. ledger
  835. .balance(&AccountId::new(1), &AssetId::new(1))
  836. .await
  837. .unwrap(),
  838. Cent::from(100)
  839. );
  840. assert_eq!(
  841. ledger
  842. .balance(&AccountId::new(2), &AssetId::new(1))
  843. .await
  844. .unwrap(),
  845. Cent::ZERO
  846. );
  847. let active = ledger
  848. .store()
  849. .get_postings_by_account(1, None, Some(&AssetId::new(1)), PostingFilter::Active)
  850. .await
  851. .unwrap();
  852. assert_eq!(active.len(), 1); // back to Active
  853. assert!(
  854. ledger
  855. .store()
  856. .list_pending_sagas()
  857. .await
  858. .unwrap()
  859. .is_empty()
  860. );
  861. }
  862. /// Recovery cannot double-spend: if the consumed posting was taken by another
  863. /// transfer while the saga was pending, recovery aborts without creating or
  864. /// storing anything.
  865. #[tokio::test]
  866. async fn recover_does_not_double_spend_a_taken_posting() {
  867. let ledger = funded_ledger().await;
  868. let envelope = ledger.resolve(&pay_transfer()).await.unwrap();
  869. let tid = envelope_id(&envelope);
  870. let rid = ReservationId::default();
  871. save_pending(&ledger, &envelope, rid, SagaPhase::Reserving).await;
  872. // Another transfer consumes account 1's posting and commits.
  873. let steal = TransferBuilder::new()
  874. .pay(
  875. AccountId::new(1),
  876. AccountId::new(3),
  877. AssetId::new(1),
  878. Cent::from(50),
  879. )
  880. .build();
  881. ledger.commit(steal).await.unwrap();
  882. assert_eq!(ledger.recover().await.unwrap(), 1);
  883. // Our envelope never committed; only the stealing transfer applied.
  884. assert!(ledger.store().get_transfer(&tid).await.unwrap().is_none());
  885. assert_eq!(
  886. ledger
  887. .balance(&AccountId::new(1), &AssetId::new(1))
  888. .await
  889. .unwrap(),
  890. Cent::from(50)
  891. );
  892. assert_eq!(
  893. ledger
  894. .balance(&AccountId::new(3), &AssetId::new(1))
  895. .await
  896. .unwrap(),
  897. Cent::from(50)
  898. );
  899. assert_eq!(
  900. ledger
  901. .balance(&AccountId::new(2), &AssetId::new(1))
  902. .await
  903. .unwrap(),
  904. Cent::ZERO
  905. );
  906. assert!(
  907. ledger
  908. .store()
  909. .list_pending_sagas()
  910. .await
  911. .unwrap()
  912. .is_empty()
  913. );
  914. }
  915. // -----------------------------------------------------------------------
  916. // Account-version transition recovery (freeze / unfreeze / close)
  917. // -----------------------------------------------------------------------
  918. /// Persist a transition write-ahead record by hand and return its id, so a
  919. /// test can simulate a crash mid-transition.
  920. async fn save_transition_record(
  921. ledger: &Arc<Ledger>,
  922. next: &Account,
  923. event: &LedgerEventKind,
  924. ) -> Result<i64, LedgerError> {
  925. let saga_id = ReservationId::default().0;
  926. let blob = serde_json::to_vec(&PendingRecord::Transition(PendingTransition {
  927. next: next.clone(),
  928. event: event.clone(),
  929. }))
  930. .map_err(|e| LedgerError::Store(StoreError::Internal(e.to_string())))?;
  931. ledger.store().save_saga(&saga_id, blob).await?;
  932. Ok(saga_id)
  933. }
  934. fn count_frozen(events: &[LedgerEvent], id: AccountId) -> usize {
  935. events
  936. .iter()
  937. .filter(|e| {
  938. matches!(
  939. e.kind,
  940. LedgerEventKind::AccountFrozen { account_id, .. } if account_id == id
  941. )
  942. })
  943. .count()
  944. }
  945. fn count_closed(events: &[LedgerEvent], id: AccountId) -> usize {
  946. events
  947. .iter()
  948. .filter(|e| {
  949. matches!(
  950. e.kind,
  951. LedgerEventKind::AccountClosed { account_id, .. } if account_id == id
  952. )
  953. })
  954. .count()
  955. }
  956. /// The happy path leaves nothing to recover: a completed freeze deletes its
  957. /// write-ahead record and emits exactly one event.
  958. #[tokio::test]
  959. async fn freeze_leaves_no_pending_record() -> Result<(), LedgerError> {
  960. let ledger = funded_ledger().await;
  961. ledger.freeze(&AccountId::new(1)).await?;
  962. assert!(
  963. ledger
  964. .store()
  965. .get_account(&AccountId::new(1))
  966. .await?
  967. .is_frozen()
  968. );
  969. let events = ledger.get_events_since(0, 1000).await?;
  970. assert_eq!(count_frozen(&events, AccountId::new(1)), 1);
  971. assert!(ledger.store().list_pending_sagas().await?.is_empty());
  972. Ok(())
  973. }
  974. /// The reported gap: a freeze crashed after the version append but before the
  975. /// event append. Recovery appends the missing event (without bumping the
  976. /// version again) and clears the record.
  977. #[tokio::test]
  978. async fn recover_completes_transition_missing_event() -> Result<(), LedgerError> {
  979. let ledger = funded_ledger().await;
  980. let current = ledger.store().get_account(&AccountId::new(1)).await?;
  981. let mut next = current;
  982. next.version += 1;
  983. next.flags |= AccountFlags::FROZEN;
  984. let event = LedgerEventKind::AccountFrozen {
  985. account_id: AccountId::new(1),
  986. version: next.version,
  987. };
  988. // Replay the transition up to (but not including) the event append.
  989. ledger.store().append_account_version(next.clone()).await?;
  990. save_transition_record(&ledger, &next, &event).await?;
  991. // Precondition: version bumped and frozen, but no event yet.
  992. assert_eq!(next.version, 2);
  993. assert_eq!(
  994. count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
  995. 0
  996. );
  997. assert_eq!(ledger.recover().await?, 1);
  998. // The event is appended, the version is not bumped a second time, and the
  999. // record is cleared.
  1000. let account = ledger.store().get_account(&AccountId::new(1)).await?;
  1001. assert!(account.is_frozen());
  1002. assert_eq!(account.version, 2);
  1003. assert_eq!(
  1004. count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
  1005. 1
  1006. );
  1007. assert!(ledger.store().list_pending_sagas().await?.is_empty());
  1008. Ok(())
  1009. }
  1010. /// A freeze that crashed before either write is rolled fully forward: recovery
  1011. /// appends the version and the event, then clears the record.
  1012. #[tokio::test]
  1013. async fn recover_completes_transition_before_any_write() -> Result<(), LedgerError> {
  1014. let ledger = funded_ledger().await;
  1015. let current = ledger.store().get_account(&AccountId::new(1)).await?;
  1016. let mut next = current;
  1017. next.version += 1;
  1018. next.flags |= AccountFlags::FROZEN;
  1019. let event = LedgerEventKind::AccountFrozen {
  1020. account_id: AccountId::new(1),
  1021. version: next.version,
  1022. };
  1023. save_transition_record(&ledger, &next, &event).await?;
  1024. // Precondition: nothing applied yet.
  1025. let before = ledger.store().get_account(&AccountId::new(1)).await?;
  1026. assert_eq!(before.version, 1);
  1027. assert!(!before.is_frozen());
  1028. assert_eq!(ledger.recover().await?, 1);
  1029. let account = ledger.store().get_account(&AccountId::new(1)).await?;
  1030. assert!(account.is_frozen());
  1031. assert_eq!(account.version, 2);
  1032. assert_eq!(
  1033. count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
  1034. 1
  1035. );
  1036. assert!(ledger.store().list_pending_sagas().await?.is_empty());
  1037. Ok(())
  1038. }
  1039. /// A transition that fully applied but whose record survived (crash before the
  1040. /// final delete) recovers idempotently: no second version, no duplicate event.
  1041. #[tokio::test]
  1042. async fn recover_transition_is_idempotent_when_already_applied() -> Result<(), LedgerError> {
  1043. let ledger = funded_ledger().await;
  1044. // A real, completed freeze: version 2, one event, no record.
  1045. ledger.freeze(&AccountId::new(1)).await?;
  1046. let next = ledger.store().get_account(&AccountId::new(1)).await?;
  1047. let event = LedgerEventKind::AccountFrozen {
  1048. account_id: AccountId::new(1),
  1049. version: next.version,
  1050. };
  1051. // Simulate the record surviving the crash window before delete_saga.
  1052. save_transition_record(&ledger, &next, &event).await?;
  1053. assert_eq!(ledger.recover().await?, 1);
  1054. let account = ledger.store().get_account(&AccountId::new(1)).await?;
  1055. assert_eq!(account.version, 2, "no second version bump");
  1056. assert_eq!(
  1057. count_frozen(&ledger.get_events_since(0, 1000).await?, AccountId::new(1)),
  1058. 1,
  1059. "event not duplicated"
  1060. );
  1061. assert!(ledger.store().list_pending_sagas().await?.is_empty());
  1062. Ok(())
  1063. }
  1064. /// A close crashed after the version append but before the event append is
  1065. /// rolled forward: recovery appends the `AccountClosed` event without a second
  1066. /// version bump and clears the record. Account 2 is empty, so it may close.
  1067. #[tokio::test]
  1068. async fn recover_completes_close_missing_event() -> Result<(), LedgerError> {
  1069. let ledger = funded_ledger().await;
  1070. let current = ledger.store().get_account(&AccountId::new(2)).await?;
  1071. let mut next = current;
  1072. next.version += 1;
  1073. next.flags |= AccountFlags::CLOSED;
  1074. let event = LedgerEventKind::AccountClosed {
  1075. account_id: AccountId::new(2),
  1076. version: next.version,
  1077. };
  1078. // Replay the transition up to (but not including) the event append.
  1079. ledger.store().append_account_version(next.clone()).await?;
  1080. save_transition_record(&ledger, &next, &event).await?;
  1081. assert_eq!(
  1082. count_closed(&ledger.get_events_since(0, 1000).await?, AccountId::new(2)),
  1083. 0
  1084. );
  1085. assert_eq!(ledger.recover().await?, 1);
  1086. let account = ledger.store().get_account(&AccountId::new(2)).await?;
  1087. assert!(account.is_closed());
  1088. assert_eq!(account.version, 2);
  1089. assert_eq!(
  1090. count_closed(&ledger.get_events_since(0, 1000).await?, AccountId::new(2)),
  1091. 1
  1092. );
  1093. assert!(ledger.store().list_pending_sagas().await?.is_empty());
  1094. Ok(())
  1095. }
  1096. /// A rejected close records nothing: the emptiness guard runs before the
  1097. /// write-ahead, so a non-empty account leaves no pending record to recover.
  1098. #[tokio::test]
  1099. async fn rejected_close_leaves_no_pending_record() -> Result<(), LedgerError> {
  1100. let ledger = funded_ledger().await;
  1101. // Account 1 holds the funded posting, so it is not empty.
  1102. let result = ledger.close(&AccountId::new(1)).await;
  1103. assert!(matches!(result, Err(LedgerError::AccountNotEmpty(_))));
  1104. assert!(ledger.store().list_pending_sagas().await?.is_empty());
  1105. Ok(())
  1106. }
  1107. }