saga.rs 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #![allow(missing_docs)]
  2. use std::sync::Arc;
  3. use kuatia::error::LedgerError;
  4. use kuatia::ledger::Ledger;
  5. use kuatia::mem_store::InMemoryStore;
  6. use kuatia::saga::*;
  7. use kuatia_core::*;
  8. use legend::{ExecutionResult, legend};
  9. use std::collections::BTreeMap;
  10. fn usd() -> AssetId {
  11. AssetId::new(1)
  12. }
  13. fn account(id: i64) -> AccountId {
  14. AccountId::new(id)
  15. }
  16. fn external() -> AccountId {
  17. AccountId::new(99)
  18. }
  19. fn make_account(id: i64, flags: AccountFlags) -> Account {
  20. Account {
  21. id: AccountId::new(id),
  22. version: 1,
  23. flags,
  24. book: BookId(0),
  25. metadata: BTreeMap::new(),
  26. }
  27. }
  28. async fn setup_ledger() -> Arc<Ledger> {
  29. let store = InMemoryStore::new();
  30. let ledger = Arc::new(Ledger::new(store));
  31. for (id, policy) in [
  32. (1, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  33. (2, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  34. (3, AccountFlags::DEBIT_MUST_NOT_EXCEED_CREDIT),
  35. (99, AccountFlags::empty()),
  36. ] {
  37. ledger
  38. .store()
  39. .create_account(make_account(id, policy))
  40. .await
  41. .unwrap();
  42. }
  43. ledger
  44. }
  45. // Define a two-step saga: deposit then pay
  46. legend! {
  47. FundAndPay<LedgerCtx, LedgerError> {
  48. deposit: DepositMovementStep,
  49. pay: PayMovementStep,
  50. }
  51. }
  52. #[tokio::test]
  53. async fn saga_happy_path() {
  54. let ledger = setup_ledger().await;
  55. let saga = FundAndPay::new(FundAndPayInputs {
  56. deposit: DepositInput {
  57. to: account(1),
  58. asset: usd(),
  59. amount: Cent::from(100),
  60. external: external(),
  61. },
  62. pay: PayInput {
  63. from: account(1),
  64. to: account(2),
  65. asset: usd(),
  66. amount: Cent::from(60),
  67. },
  68. });
  69. let ctx = LedgerCtx::new(ledger.clone());
  70. let execution = saga.build(ctx);
  71. match execution.start().await {
  72. ExecutionResult::Completed(e) => {
  73. assert_eq!(e.context().receipts.len(), 2);
  74. }
  75. other => panic!("expected Completed, got {:?}", result_debug(&other)),
  76. }
  77. assert_eq!(
  78. ledger.balance(&account(1), &usd()).await.unwrap(),
  79. Cent::from(40)
  80. );
  81. assert_eq!(
  82. ledger.balance(&account(2), &usd()).await.unwrap(),
  83. Cent::from(60)
  84. );
  85. assert_eq!(
  86. ledger.balance(&external(), &usd()).await.unwrap(),
  87. Cent::from(-100)
  88. );
  89. }
  90. // Define a saga that will fail on the second step and trigger compensation
  91. legend! {
  92. DepositAndOverspend<LedgerCtx, LedgerError> {
  93. deposit: DepositMovementStep,
  94. pay: PayMovementStep,
  95. }
  96. }
  97. #[tokio::test]
  98. async fn saga_compensation_on_failure() {
  99. let ledger = setup_ledger().await;
  100. // Deposit 50 then try to pay 100 -> pay fails -> deposit should be reversed
  101. let saga = DepositAndOverspend::new(DepositAndOverspendInputs {
  102. deposit: DepositInput {
  103. to: account(1),
  104. asset: usd(),
  105. amount: Cent::from(50),
  106. external: external(),
  107. },
  108. pay: PayInput {
  109. from: account(1),
  110. to: account(2),
  111. asset: usd(),
  112. amount: Cent::from(100), // more than available
  113. },
  114. });
  115. let ctx = LedgerCtx::new(ledger.clone());
  116. let execution = saga.build(ctx);
  117. match execution.start().await {
  118. ExecutionResult::Failed(_, err) => {
  119. // The saga carries the typed `LedgerError` across its step seam, so
  120. // the overspend surfaces as `Selection(InsufficientFunds)` rather
  121. // than a stringified `Store(Internal)`.
  122. assert!(
  123. matches!(
  124. err,
  125. LedgerError::Selection(SelectionError::InsufficientFunds { .. })
  126. ),
  127. "expected typed InsufficientFunds, got {err:?}"
  128. );
  129. // The deposit should have been compensated (reversed)
  130. // Note: balances won't be exactly 0 because the deposit reversal
  131. // creates new postings, but the net effect should be zero
  132. assert_eq!(
  133. ledger.balance(&account(1), &usd()).await.unwrap(),
  134. Cent::ZERO
  135. );
  136. assert_eq!(
  137. ledger.balance(&external(), &usd()).await.unwrap(),
  138. Cent::ZERO
  139. );
  140. }
  141. other => panic!("expected Failed, got {:?}", result_debug(&other)),
  142. }
  143. }
  144. // Three-step saga
  145. legend! {
  146. ThreeStepFlow<LedgerCtx, LedgerError> {
  147. deposit: DepositMovementStep,
  148. pay_ab: PayMovementStep,
  149. pay_bc: PayMovementStep,
  150. }
  151. }
  152. #[tokio::test]
  153. async fn saga_three_steps_happy() {
  154. let ledger = setup_ledger().await;
  155. let saga = ThreeStepFlow::new(ThreeStepFlowInputs {
  156. deposit: DepositInput {
  157. to: account(1),
  158. asset: usd(),
  159. amount: Cent::from(100),
  160. external: external(),
  161. },
  162. pay_ab: PayInput {
  163. from: account(1),
  164. to: account(2),
  165. asset: usd(),
  166. amount: Cent::from(60),
  167. },
  168. pay_bc: PayInput {
  169. from: account(2),
  170. to: account(3),
  171. asset: usd(),
  172. amount: Cent::from(30),
  173. },
  174. });
  175. let ctx = LedgerCtx::new(ledger.clone());
  176. let execution = saga.build(ctx);
  177. match execution.start().await {
  178. ExecutionResult::Completed(e) => {
  179. assert_eq!(e.context().receipts.len(), 3);
  180. }
  181. other => panic!("expected Completed, got {:?}", result_debug(&other)),
  182. }
  183. assert_eq!(
  184. ledger.balance(&account(1), &usd()).await.unwrap(),
  185. Cent::from(40)
  186. );
  187. assert_eq!(
  188. ledger.balance(&account(2), &usd()).await.unwrap(),
  189. Cent::from(30)
  190. );
  191. assert_eq!(
  192. ledger.balance(&account(3), &usd()).await.unwrap(),
  193. Cent::from(30)
  194. );
  195. }
  196. fn result_debug<Ctx, Err, Steps>(r: &ExecutionResult<Ctx, Err, Steps>) -> &'static str
  197. where
  198. Ctx: Send + Sync,
  199. Err: Send + Sync + Clone,
  200. Steps: legend::hlist::InstructionList<Ctx, Err>,
  201. {
  202. match r {
  203. ExecutionResult::Completed(_) => "Completed",
  204. ExecutionResult::Paused(_) => "Paused",
  205. ExecutionResult::Failed(_, _) => "Failed",
  206. ExecutionResult::CompensationFailed { .. } => "CompensationFailed",
  207. }
  208. }