concurrency.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. //! Concurrency tests for the saga commit pipeline over `InMemoryStore`.
  2. //!
  3. //! `InMemoryStore` guards each field with a `tokio::RwLock`, so every individual
  4. //! `Store` primitive is atomic. A saga, however, is a *sequence* of primitives
  5. //! with no overarching lock, so the interesting races live between primitives
  6. //! across concurrent sagas that share one `Arc<Ledger>`. The generated
  7. //! conformance suite only drives the store sequentially, so none of this is
  8. //! covered there.
  9. //!
  10. //! These tests run on a multi-thread runtime and use `tokio::spawn` so the
  11. //! sagas genuinely interleave rather than run to completion one at a time.
  12. #![allow(missing_docs)]
  13. use std::collections::BTreeMap;
  14. use std::sync::Arc;
  15. use kuatia::ledger::Ledger;
  16. use kuatia::mem_store::InMemoryStore;
  17. use kuatia_core::*;
  18. fn usd() -> AssetId {
  19. AssetId::new(1)
  20. }
  21. fn account(id: i64) -> AccountId {
  22. AccountId::new(id)
  23. }
  24. fn external() -> AccountId {
  25. AccountId::new(99)
  26. }
  27. fn make_account(id: i64, policy: AccountPolicy) -> Account {
  28. Account {
  29. id: AccountId::new(id),
  30. version: 1,
  31. policy,
  32. flags: AccountFlags::empty(),
  33. book: BookId(0),
  34. metadata: BTreeMap::new(),
  35. }
  36. }
  37. /// A ledger with `NoOverdraft` accounts `1..=n` plus an external account.
  38. async fn ledger_with_accounts(n: i64) -> Arc<Ledger> {
  39. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  40. for id in 1..=n {
  41. ledger
  42. .store()
  43. .create_account(make_account(id, AccountPolicy::NoOverdraft))
  44. .await
  45. .unwrap();
  46. }
  47. ledger
  48. .store()
  49. .create_account(make_account(99, AccountPolicy::ExternalAccount))
  50. .await
  51. .unwrap();
  52. ledger
  53. }
  54. async fn deposit(ledger: &Arc<Ledger>, to: AccountId, amount: Cent) {
  55. let transfer = TransferBuilder::new()
  56. .deposit(to, usd(), amount, external())
  57. .unwrap()
  58. .build();
  59. ledger.commit(transfer).await.unwrap();
  60. }
  61. // ---------------------------------------------------------------------------
  62. // 1. Double-spend prevention (the headline invariant)
  63. // ---------------------------------------------------------------------------
  64. /// Many transfers concurrently try to spend the *same* funded posting to
  65. /// different recipients. Exactly one may win: the winner's `reserve_postings`
  66. /// flips the single Active posting to `PendingInactive`, and every other saga's
  67. /// reserve returns zero for a fresh reservation, so it fails and compensates.
  68. /// The ledger stays conserved: the payer ends at zero and exactly one recipient
  69. /// receives the full amount.
  70. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  71. async fn concurrent_double_spend_has_one_winner() {
  72. const RECIPIENTS: i64 = 8;
  73. let ledger = ledger_with_accounts(1 + RECIPIENTS).await;
  74. // Account 1 holds a single Active posting of 100.
  75. deposit(&ledger, account(1), Cent::from(100)).await;
  76. // Fire one full-balance payment per recipient, all at once.
  77. let mut handles = Vec::new();
  78. for recipient in 2..=(1 + RECIPIENTS) {
  79. let ledger = Arc::clone(&ledger);
  80. handles.push(tokio::spawn(async move {
  81. let transfer = TransferBuilder::new()
  82. .pay(account(1), account(recipient), usd(), Cent::from(100))
  83. .build();
  84. ledger.commit(transfer).await
  85. }));
  86. }
  87. let mut winners = 0;
  88. for h in handles {
  89. if h.await.unwrap().is_ok() {
  90. winners += 1;
  91. }
  92. }
  93. assert_eq!(winners, 1, "exactly one concurrent spend may succeed");
  94. // Conservation: payer drained, exactly one recipient credited, total = 100.
  95. assert_eq!(
  96. ledger.balance(&account(1), &usd()).await.unwrap(),
  97. Cent::ZERO
  98. );
  99. let mut credited = 0;
  100. let mut total = Cent::ZERO;
  101. for recipient in 2..=(1 + RECIPIENTS) {
  102. let bal = ledger.balance(&account(recipient), &usd()).await.unwrap();
  103. if bal != Cent::ZERO {
  104. credited += 1;
  105. assert_eq!(bal, Cent::from(100));
  106. }
  107. total = total.checked_add(bal).unwrap();
  108. }
  109. assert_eq!(credited, 1, "exactly one recipient is credited");
  110. assert_eq!(total, Cent::from(100), "value is conserved");
  111. }
  112. // ---------------------------------------------------------------------------
  113. // 2. Idempotency
  114. // ---------------------------------------------------------------------------
  115. /// Re-committing an already-committed envelope returns the same receipt and does
  116. /// not move value a second time. This is the sequential idempotency contract
  117. /// that `commit_envelope` guarantees via its content-addressed short-circuit.
  118. #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
  119. async fn recommit_same_envelope_is_idempotent() {
  120. let ledger = ledger_with_accounts(2).await;
  121. deposit(&ledger, account(1), Cent::from(100)).await;
  122. let transfer = TransferBuilder::new()
  123. .pay(account(1), account(2), usd(), Cent::from(50))
  124. .build();
  125. let envelope = ledger.resolve(&transfer).await.unwrap();
  126. let first = ledger.commit_envelope(envelope.clone()).await.unwrap();
  127. let second = ledger.commit_envelope(envelope).await.unwrap();
  128. assert_eq!(first, second, "replay returns the original receipt");
  129. assert_eq!(
  130. ledger.balance(&account(1), &usd()).await.unwrap(),
  131. Cent::from(50)
  132. );
  133. assert_eq!(
  134. ledger.balance(&account(2), &usd()).await.unwrap(),
  135. Cent::from(50)
  136. );
  137. }
  138. /// The same envelope committed concurrently from many tasks. Because the
  139. /// content-addressed id is the idempotency key, value moves exactly once no
  140. /// matter how the sagas interleave: some tasks win or observe the stored
  141. /// transfer and return its receipt; the rest lose the reservation race and
  142. /// fail. Every successful receipt is identical, and the balances move once.
  143. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  144. async fn concurrent_identical_commits_move_value_once() {
  145. const TASKS: usize = 8;
  146. let ledger = ledger_with_accounts(2).await;
  147. deposit(&ledger, account(1), Cent::from(100)).await;
  148. let transfer = TransferBuilder::new()
  149. .pay(account(1), account(2), usd(), Cent::from(50))
  150. .build();
  151. let envelope = ledger.resolve(&transfer).await.unwrap();
  152. let mut handles = Vec::new();
  153. for _ in 0..TASKS {
  154. let ledger = Arc::clone(&ledger);
  155. let envelope = envelope.clone();
  156. handles.push(tokio::spawn(async move {
  157. ledger.commit_envelope(envelope).await
  158. }));
  159. }
  160. let mut receipts = Vec::new();
  161. for h in handles {
  162. if let Ok(receipt) = h.await.unwrap() {
  163. receipts.push(receipt);
  164. }
  165. }
  166. assert!(!receipts.is_empty(), "at least one commit succeeds");
  167. let first = &receipts[0];
  168. assert!(
  169. receipts.iter().all(|r| r == first),
  170. "every successful commit returns the same receipt"
  171. );
  172. // Value moved exactly once, and exactly one transfer is stored.
  173. assert_eq!(
  174. ledger.balance(&account(1), &usd()).await.unwrap(),
  175. Cent::from(50)
  176. );
  177. assert_eq!(
  178. ledger.balance(&account(2), &usd()).await.unwrap(),
  179. Cent::from(50)
  180. );
  181. assert!(
  182. ledger
  183. .store()
  184. .get_transfer(&first.transfer_id)
  185. .await
  186. .unwrap()
  187. .is_some(),
  188. "the committed transfer is persisted"
  189. );
  190. }
  191. // ---------------------------------------------------------------------------
  192. // 3. Freeze vs. commit race
  193. // ---------------------------------------------------------------------------
  194. /// Freezing an account concurrently with a payment out of it must leave a
  195. /// consistent state. The account is versioned and the commit pins the snapshot
  196. /// it validated against, so the two serialize one way or the other: either the
  197. /// payment finalizes first (against the unfrozen snapshot) and the freeze lands
  198. /// on top, or the freeze bumps the version first and the commit's last-step
  199. /// re-validation rejects the now-frozen account. There is no middle ground where
  200. /// value moves out of a frozen account against a stale snapshot. Value is always
  201. /// conserved and the payment is all-or-nothing.
  202. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  203. async fn freeze_during_commit_stays_consistent() {
  204. // Race is timing-dependent; run several fresh rounds to sample interleavings.
  205. for _ in 0..24 {
  206. let ledger = ledger_with_accounts(2).await;
  207. deposit(&ledger, account(1), Cent::from(100)).await;
  208. let freezer = {
  209. let ledger = Arc::clone(&ledger);
  210. tokio::spawn(async move { ledger.freeze(&account(1)).await })
  211. };
  212. let payer = {
  213. let ledger = Arc::clone(&ledger);
  214. tokio::spawn(async move {
  215. let transfer = TransferBuilder::new()
  216. .pay(account(1), account(2), usd(), Cent::from(50))
  217. .build();
  218. ledger.commit(transfer).await
  219. })
  220. };
  221. freezer.await.unwrap().expect("freeze always succeeds");
  222. let paid = payer.await.unwrap().is_ok();
  223. let b1 = ledger.balance(&account(1), &usd()).await.unwrap();
  224. let b2 = ledger.balance(&account(2), &usd()).await.unwrap();
  225. // Conservation and all-or-nothing, keyed on whether the pay committed.
  226. assert_eq!(
  227. b1.checked_add(b2).unwrap(),
  228. Cent::from(100),
  229. "value is conserved regardless of who won"
  230. );
  231. if paid {
  232. assert_eq!(b1, Cent::from(50));
  233. assert_eq!(b2, Cent::from(50));
  234. } else {
  235. assert_eq!(b1, Cent::from(100));
  236. assert_eq!(b2, Cent::ZERO);
  237. }
  238. // The account is frozen either way; no further payment may leave it.
  239. assert!(ledger.get_account(&account(1)).await.unwrap().is_frozen());
  240. let after = TransferBuilder::new()
  241. .pay(account(1), account(2), usd(), Cent::from(10))
  242. .build();
  243. assert!(
  244. ledger.commit(after).await.is_err(),
  245. "a frozen account cannot pay"
  246. );
  247. }
  248. }
  249. // ---------------------------------------------------------------------------
  250. // 4. Disjoint transfers all commit and conserve
  251. // ---------------------------------------------------------------------------
  252. /// Concurrent transfers over non-overlapping accounts never contend, so all of
  253. /// them commit and total value is conserved. This is the throughput counterpart
  254. /// to the double-spend test: parallelism is only constrained where postings are
  255. /// actually shared.
  256. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  257. async fn disjoint_transfers_all_commit_and_conserve() {
  258. const PAIRS: i64 = 8;
  259. // Accounts 1..=2*PAIRS: odd = payer (funded), even = payee.
  260. let ledger = ledger_with_accounts(2 * PAIRS).await;
  261. for k in 0..PAIRS {
  262. deposit(&ledger, account(2 * k + 1), Cent::from(100)).await;
  263. }
  264. let mut handles = Vec::new();
  265. for k in 0..PAIRS {
  266. let ledger = Arc::clone(&ledger);
  267. handles.push(tokio::spawn(async move {
  268. let transfer = TransferBuilder::new()
  269. .pay(
  270. account(2 * k + 1),
  271. account(2 * k + 2),
  272. usd(),
  273. Cent::from(100),
  274. )
  275. .build();
  276. ledger.commit(transfer).await
  277. }));
  278. }
  279. for h in handles {
  280. h.await.unwrap().expect("disjoint transfers never contend");
  281. }
  282. let mut total = Cent::ZERO;
  283. for id in 1..=(2 * PAIRS) {
  284. let bal = ledger.balance(&account(id), &usd()).await.unwrap();
  285. let expected = if id % 2 == 0 {
  286. Cent::from(100)
  287. } else {
  288. Cent::ZERO
  289. };
  290. assert_eq!(bal, expected, "account {id} settled");
  291. total = total.checked_add(bal).unwrap();
  292. }
  293. assert_eq!(total, Cent::from(100 * PAIRS), "value is conserved");
  294. }
  295. // ---------------------------------------------------------------------------
  296. // 5. Overdraft floor is best-effort under concurrency (documented limitation)
  297. // ---------------------------------------------------------------------------
  298. /// Documents a known, accepted limitation: the `CappedOverdraft` floor is
  299. /// re-checked at the last step before writing, but that check is not atomic
  300. /// with the write. Two overdrafts that each pass the floor check against the
  301. /// same pre-transfer balance can both commit and jointly push the account below
  302. /// its floor. See `doc/transfers.md`.
  303. ///
  304. /// This test is `#[ignore]`d because the breach is timing-dependent, so it is
  305. /// executable documentation rather than a CI assertion. What always holds, and
  306. /// what it does assert, is per-asset conservation: the overdraft's negative
  307. /// postings are real value owed, never minted. If a run drives the account below
  308. /// the floor, that is the documented behavior, not a conservation failure.
  309. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  310. #[ignore = "documents the best-effort overdraft floor; breach is timing-dependent"]
  311. async fn overdraft_floor_is_best_effort_under_concurrency() {
  312. let floor = Cent::from(-100);
  313. let mut observed_breach = false;
  314. const PAYEES: i64 = 8;
  315. for _ in 0..64 {
  316. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  317. ledger
  318. .store()
  319. .create_account(make_account(1, AccountPolicy::CappedOverdraft { floor }))
  320. .await
  321. .unwrap();
  322. for payee in 2..=(1 + PAYEES) {
  323. ledger
  324. .store()
  325. .create_account(make_account(payee, AccountPolicy::NoOverdraft))
  326. .await
  327. .unwrap();
  328. }
  329. // One payment of 60 to each distinct payee from an empty overdraft
  330. // account (distinct payees keep the envelopes distinct, so they are not
  331. // collapsed by content-addressed idempotency). Each alone projects to
  332. // -60 (within the -100 floor); any two that slip through the last-step
  333. // floor check together already breach it.
  334. let mut handles = Vec::new();
  335. for payee in 2..=(1 + PAYEES) {
  336. let ledger = Arc::clone(&ledger);
  337. handles.push(tokio::spawn(async move {
  338. let transfer = TransferBuilder::new()
  339. .pay(account(1), account(payee), usd(), Cent::from(60))
  340. .build();
  341. ledger.commit(transfer).await
  342. }));
  343. }
  344. for h in handles {
  345. let _ = h.await.unwrap();
  346. }
  347. let mut total = ledger.balance(&account(1), &usd()).await.unwrap();
  348. for payee in 2..=(1 + PAYEES) {
  349. total = total
  350. .checked_add(ledger.balance(&account(payee), &usd()).await.unwrap())
  351. .unwrap();
  352. }
  353. assert_eq!(
  354. total,
  355. Cent::ZERO,
  356. "value is conserved even when the floor is breached"
  357. );
  358. if ledger.balance(&account(1), &usd()).await.unwrap() < floor {
  359. observed_breach = true;
  360. }
  361. }
  362. eprintln!(
  363. "overdraft floor breach observed under concurrency: {observed_breach} \
  364. (best-effort by design; see doc/transfers.md)"
  365. );
  366. }