saga.rs 6.2 KB

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