projection.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. //! Balance-projection correctness (ADR-0019): the projection-aware read must
  2. //! always equal the authoritative live-posting sum, and the projector must
  3. //! advance the snapshot without changing the answer.
  4. #![allow(missing_docs)]
  5. use std::sync::Arc;
  6. use kuatia::ledger::Ledger;
  7. use kuatia::mem_store::InMemoryStore;
  8. use kuatia_core::*;
  9. fn usd() -> AssetId {
  10. AssetId::new(1)
  11. }
  12. fn account(id: i64) -> AccountId {
  13. AccountId::new(id)
  14. }
  15. fn external() -> AccountId {
  16. AccountId::new(99)
  17. }
  18. async fn no_overdraft(ledger: &Arc<Ledger>, id: i64) {
  19. ledger
  20. .store()
  21. .create_account(Account::debit_must_not_exceed_credit(account(id)))
  22. .await
  23. .unwrap();
  24. }
  25. async fn overdraft(ledger: &Arc<Ledger>, id: i64) {
  26. ledger
  27. .store()
  28. .create_account(Account::new(account(id)))
  29. .await
  30. .unwrap();
  31. }
  32. async fn deposit(ledger: &Arc<Ledger>, to: i64, amount: i64) {
  33. let transfer = TransferBuilder::new()
  34. .deposit(account(to), usd(), Cent::from(amount), external())
  35. .unwrap()
  36. .build();
  37. ledger.commit(transfer).await.unwrap();
  38. }
  39. /// Current time as Unix milliseconds, the same clock the ledger stamps commits
  40. /// and cache-point watermarks with, so a test can pin a watermark to "now".
  41. fn unix_millis_now() -> i64 {
  42. std::time::SystemTime::now()
  43. .duration_since(std::time::UNIX_EPOCH)
  44. .unwrap()
  45. .as_millis() as i64
  46. }
  47. async fn pay(ledger: &Arc<Ledger>, from: i64, to: i64, amount: i64) {
  48. let transfer = TransferBuilder::new()
  49. .pay(account(from), account(to), usd(), Cent::from(amount))
  50. .build();
  51. ledger.commit(transfer).await.unwrap();
  52. }
  53. /// Assert the projection-aware read equals the authoritative live-posting sum
  54. /// for every account, at every asset it might hold.
  55. async fn assert_projection_matches(ledger: &Arc<Ledger>, ids: &[i64]) {
  56. for &id in ids {
  57. let authoritative = ledger.compute_balance(&account(id), &usd()).await.unwrap();
  58. let projected = ledger.balance(&account(id), &usd()).await.unwrap();
  59. assert_eq!(
  60. projected, authoritative,
  61. "projected balance for account {id} diverged from the live sum"
  62. );
  63. }
  64. }
  65. /// With no projector run at all (every projection absent, so the read folds the
  66. /// whole history), the projection-aware read still equals the live sum through
  67. /// deposits, change-making pays, and an overdraft offset posting.
  68. #[tokio::test]
  69. async fn projected_balance_matches_live_sum_without_refresh() {
  70. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  71. no_overdraft(&ledger, 1).await;
  72. no_overdraft(&ledger, 2).await;
  73. overdraft(&ledger, 10).await;
  74. overdraft(&ledger, 99).await;
  75. deposit(&ledger, 1, 1000).await;
  76. // Several pays fragment account 1 into many change postings.
  77. pay(&ledger, 1, 2, 150).await;
  78. pay(&ledger, 1, 2, 70).await;
  79. pay(&ledger, 1, 2, 30).await;
  80. pay(&ledger, 2, 1, 40).await;
  81. // Overdraft: account 10 (empty balance) pays into a negative offset posting.
  82. pay(&ledger, 10, 2, 500).await;
  83. assert_projection_matches(&ledger, &[1, 2, 10, 99]).await;
  84. }
  85. /// Appending a cache point stores the balance directly, and the read still equals
  86. /// the live sum. Appending only after all commits keeps this deterministic: grace
  87. /// 0 puts the watermark at "now", so every committed transfer folds into the cache
  88. /// point with an empty tail.
  89. #[tokio::test]
  90. async fn append_cache_point_snapshots_balance_and_preserves_answer() {
  91. let ledger = Arc::new(Ledger::new(InMemoryStore::new()).with_projection_grace_ms(0));
  92. no_overdraft(&ledger, 1).await;
  93. no_overdraft(&ledger, 2).await;
  94. overdraft(&ledger, 99).await;
  95. deposit(&ledger, 1, 1000).await;
  96. pay(&ledger, 1, 2, 250).await;
  97. pay(&ledger, 1, 2, 100).await;
  98. ledger
  99. .append_cache_point(&account(1), &usd())
  100. .await
  101. .unwrap();
  102. // The cache point holds account 1's balance (1000 - 250 - 100) directly.
  103. let cache_point = ledger
  104. .store()
  105. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  106. .await
  107. .unwrap()
  108. .expect("a cache point exists after append");
  109. assert_eq!(cache_point.balance, Cent::from(650));
  110. assert_eq!(
  111. ledger.balance(&account(1), &usd()).await.unwrap(),
  112. Cent::from(650)
  113. );
  114. assert_projection_matches(&ledger, &[1, 2]).await;
  115. }
  116. /// Commit never writes a cache point: after commits with no read, none exists.
  117. #[tokio::test]
  118. async fn commit_does_not_write_cache_point() {
  119. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  120. no_overdraft(&ledger, 1).await;
  121. no_overdraft(&ledger, 2).await;
  122. overdraft(&ledger, 99).await;
  123. deposit(&ledger, 1, 1000).await;
  124. pay(&ledger, 1, 2, 250).await;
  125. // No read has happened, so the lazy append never fired.
  126. assert!(
  127. ledger
  128. .store()
  129. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  130. .await
  131. .unwrap()
  132. .is_none()
  133. );
  134. }
  135. /// A read appends a cache point once `snapshot_interval` credits/debits have
  136. /// accrued (the append is spawned in the background; on the current-thread test
  137. /// runtime it runs when we yield).
  138. #[tokio::test]
  139. async fn read_appends_cache_point_after_interval() {
  140. let ledger = Arc::new(
  141. Ledger::new(InMemoryStore::new())
  142. .with_projection_grace_ms(0)
  143. .with_snapshot_interval(1),
  144. );
  145. no_overdraft(&ledger, 1).await;
  146. no_overdraft(&ledger, 2).await;
  147. overdraft(&ledger, 99).await;
  148. deposit(&ledger, 1, 1000).await;
  149. pay(&ledger, 1, 2, 250).await;
  150. // This read folds >= 1 credit/debit for account 1, so it spawns an append.
  151. let _ = ledger.balance(&account(1), &usd()).await.unwrap();
  152. // Let the background append run, then confirm a cache point exists.
  153. let mut appeared = false;
  154. for _ in 0..1000 {
  155. tokio::task::yield_now().await;
  156. if ledger
  157. .store()
  158. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  159. .await
  160. .unwrap()
  161. .is_some()
  162. {
  163. appeared = true;
  164. break;
  165. }
  166. }
  167. assert!(
  168. appeared,
  169. "a read past the interval should append a cache point"
  170. );
  171. assert_eq!(
  172. ledger.balance(&account(1), &usd()).await.unwrap(),
  173. ledger.compute_balance(&account(1), &usd()).await.unwrap()
  174. );
  175. }
  176. /// A read below the interval never appends a cache point: the background append
  177. /// gates on new credits/debits, so a low-activity account accrues no rows (this
  178. /// is what keeps a hot, frequently-read account from appending near-duplicates).
  179. #[tokio::test]
  180. async fn read_below_interval_appends_nothing() {
  181. let ledger = Arc::new(
  182. Ledger::new(InMemoryStore::new())
  183. .with_projection_grace_ms(0)
  184. .with_snapshot_interval(1_000),
  185. );
  186. no_overdraft(&ledger, 1).await;
  187. no_overdraft(&ledger, 2).await;
  188. overdraft(&ledger, 99).await;
  189. deposit(&ledger, 1, 1000).await;
  190. pay(&ledger, 1, 2, 250).await;
  191. // A handful of credits/debits, far below the 1000 interval.
  192. let bal = ledger.balance(&account(1), &usd()).await.unwrap();
  193. assert_eq!(
  194. bal,
  195. ledger.compute_balance(&account(1), &usd()).await.unwrap()
  196. );
  197. // Even after the background task has every chance to run, no cache point was
  198. // appended (the append gates on >= interval new credits/debits).
  199. for _ in 0..1000 {
  200. tokio::task::yield_now().await;
  201. }
  202. assert!(
  203. ledger
  204. .store()
  205. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  206. .await
  207. .unwrap()
  208. .is_none(),
  209. "a below-interval read must not append a cache point"
  210. );
  211. }
  212. /// Snapshot plus a genuinely non-empty tail. A cache point is pinned to the
  213. /// boundary between two commit batches, then more commits land strictly after
  214. /// its watermark, so the read must fold `snapshot + tail`: not a whole-history
  215. /// live sum (no snapshot) and not a snapshot that already holds everything (empty
  216. /// tail). This is the watermark boundary the grace-0 and no-snapshot tests never
  217. /// reach; an off-by-one at `watermark + 1` would drop or double-count a
  218. /// tail transfer here.
  219. #[tokio::test]
  220. async fn snapshot_plus_nonempty_tail_matches_live_sum() {
  221. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  222. no_overdraft(&ledger, 1).await;
  223. no_overdraft(&ledger, 2).await;
  224. overdraft(&ledger, 99).await;
  225. // Batch 1: the snapshot will cover exactly this.
  226. deposit(&ledger, 1, 1000).await;
  227. pay(&ledger, 1, 2, 250).await;
  228. // Pin a cache point at the batch-1/batch-2 boundary. Its balance is the
  229. // authoritative batch-1 sum (folded by the ledger, not by hand); its watermark
  230. // is a time at or after every batch-1 commit. Appended directly so the
  231. // watermark is controlled, independent of the lazy read-path grace.
  232. let snapshot = ledger.compute_balance(&account(1), &usd()).await.unwrap();
  233. let watermark = unix_millis_now();
  234. ledger
  235. .store()
  236. .append_balance_projection(&account(1), &usd(), snapshot, watermark)
  237. .await
  238. .unwrap();
  239. // Cross the millisecond boundary so every batch-2 commit is stamped strictly
  240. // after the watermark and lands in the tail, never the snapshot.
  241. tokio::time::sleep(std::time::Duration::from_millis(3)).await;
  242. // Batch 2: folded onto the snapshot as a non-empty tail (both credits and
  243. // debits for account 1).
  244. pay(&ledger, 1, 2, 100).await;
  245. pay(&ledger, 2, 1, 40).await;
  246. deposit(&ledger, 1, 500).await;
  247. // The read is snapshot + folded tail, and still equals the live sum.
  248. let projected = ledger.balance(&account(1), &usd()).await.unwrap();
  249. let authoritative = ledger.compute_balance(&account(1), &usd()).await.unwrap();
  250. assert_eq!(
  251. projected, authoritative,
  252. "snapshot + non-empty tail diverged from the live sum"
  253. );
  254. // Guard the test itself: the snapshot must have been partial, so the tail
  255. // actually carried value. Otherwise this silently degrades to the empty-tail
  256. // case the other tests already cover.
  257. assert_ne!(
  258. snapshot, authoritative,
  259. "the snapshot already held the full balance; the tail was empty"
  260. );
  261. assert_projection_matches(&ledger, &[1, 2]).await;
  262. }
  263. /// Deterministic xorshift64 PRNG so the property test is reproducible.
  264. struct Rng(u64);
  265. impl Rng {
  266. fn next(&mut self) -> u64 {
  267. let mut x = self.0;
  268. x ^= x << 13;
  269. x ^= x >> 7;
  270. x ^= x << 17;
  271. self.0 = x;
  272. x
  273. }
  274. fn below(&mut self, n: u64) -> u64 {
  275. self.next() % n
  276. }
  277. }
  278. /// Property test: across a long random sequence of every UTXO-shaped operation
  279. /// (deposits, change-making pays, overdraft offsets, multi-asset, subaccounts,
  280. /// and reversals), the projection-aware read equals the authoritative live-posting
  281. /// sum for every (account, asset) after every step. This is the empirical form of
  282. /// the telescoping argument: whole-posting spends plus change-as-new-posting make
  283. /// `snapshot + tail` fold exactly to the live set, in every shape the ledger can
  284. /// produce.
  285. #[tokio::test]
  286. async fn projection_matches_live_sum_across_random_utxo_history() {
  287. // Default grace (60s) over a sub-second run keeps every watermark before all
  288. // commits, so no cache point is appended and each read folds the full tail
  289. // (equal to the live sum). That makes the run deterministic and free of
  290. // same-millisecond append races; the invariant holds with or without a cache
  291. // point. The snapshot-plus-non-empty-tail path (a real snapshot with commits
  292. // folded on top) is covered deterministically by
  293. // `snapshot_plus_nonempty_tail_matches_live_sum`.
  294. let ledger = Arc::new(Ledger::new(InMemoryStore::new()).with_snapshot_interval(4));
  295. // Accounts under test, including two subaccounts and both overdraft kinds.
  296. let no_overdraft_ids = [
  297. AccountId::new(1),
  298. AccountId::new(2),
  299. AccountId::new(3),
  300. AccountId::with_sub(1, 7),
  301. ];
  302. let overdraft_ids = [
  303. AccountId::new(10),
  304. AccountId::new(11),
  305. AccountId::with_sub(11, 3),
  306. ];
  307. for id in no_overdraft_ids {
  308. ledger
  309. .store()
  310. .create_account(Account::debit_must_not_exceed_credit(id))
  311. .await
  312. .unwrap();
  313. }
  314. for id in overdraft_ids {
  315. ledger
  316. .store()
  317. .create_account(Account::new(id))
  318. .await
  319. .unwrap();
  320. }
  321. let ext = external();
  322. ledger
  323. .store()
  324. .create_account(Account::new(ext))
  325. .await
  326. .unwrap();
  327. let accounts: Vec<AccountId> = no_overdraft_ids
  328. .iter()
  329. .chain(overdraft_ids.iter())
  330. .copied()
  331. .collect();
  332. let assets = [AssetId::new(1), AssetId::new(2)];
  333. let mut rng = Rng(0x9e3779b97f4a7c15);
  334. let mut receipts: Vec<EnvelopeId> = Vec::new();
  335. for _ in 0..300 {
  336. let asset = assets[rng.below(assets.len() as u64) as usize];
  337. match rng.below(5) {
  338. // Deposit into a random account.
  339. 0 => {
  340. let to = accounts[rng.below(accounts.len() as u64) as usize];
  341. let amount = 1 + rng.below(500) as i64;
  342. let t = TransferBuilder::new()
  343. .deposit(to, asset, Cent::from(amount), ext)
  344. .unwrap()
  345. .build();
  346. if let Ok(r) = ledger.commit(t).await {
  347. receipts.push(r.transfer_id);
  348. }
  349. }
  350. // Withdraw from a random account to the boundary.
  351. 1 => {
  352. let from = accounts[rng.below(accounts.len() as u64) as usize];
  353. let amount = 1 + rng.below(200) as i64;
  354. let t = TransferBuilder::new()
  355. .withdraw(from, asset, Cent::from(amount), ext)
  356. .build();
  357. if let Ok(r) = ledger.commit(t).await {
  358. receipts.push(r.transfer_id);
  359. }
  360. }
  361. // Reverse a previously committed transfer.
  362. 2 if !receipts.is_empty() => {
  363. let id = receipts[rng.below(receipts.len() as u64) as usize];
  364. if let Ok(r) = ledger.reverse(&id).await {
  365. receipts.push(r.transfer_id);
  366. }
  367. }
  368. // Pay between two random accounts (change / overdraft / cross-subaccount).
  369. _ => {
  370. let from = accounts[rng.below(accounts.len() as u64) as usize];
  371. let to = accounts[rng.below(accounts.len() as u64) as usize];
  372. if from == to {
  373. continue;
  374. }
  375. let amount = 1 + rng.below(300) as i64;
  376. let t = TransferBuilder::new()
  377. .pay(from, to, asset, Cent::from(amount))
  378. .build();
  379. if let Ok(r) = ledger.commit(t).await {
  380. receipts.push(r.transfer_id);
  381. }
  382. }
  383. }
  384. // Invariant: at rest after every step, the projection-aware read equals
  385. // the authoritative live-posting sum for every (account, asset).
  386. for account in accounts.iter().chain(std::iter::once(&ext)) {
  387. for asset in &assets {
  388. let authoritative = ledger.compute_balance(account, asset).await.unwrap();
  389. let projected = ledger.balance(account, asset).await.unwrap();
  390. assert_eq!(
  391. projected, authoritative,
  392. "projected != live sum for {account:?} / {asset:?}"
  393. );
  394. }
  395. }
  396. }
  397. }
  398. /// Concurrent commits from one funded account: after the dust settles, the
  399. /// projection-aware read agrees with the live sum for every participant.
  400. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  401. async fn projection_matches_under_concurrent_commits() {
  402. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  403. no_overdraft(&ledger, 1).await;
  404. for id in 2..=9 {
  405. no_overdraft(&ledger, id).await;
  406. }
  407. overdraft(&ledger, 99).await;
  408. deposit(&ledger, 1, 1000).await;
  409. let mut handles = Vec::new();
  410. for payee in 2..=9 {
  411. let ledger = Arc::clone(&ledger);
  412. handles.push(tokio::spawn(async move {
  413. let transfer = TransferBuilder::new()
  414. .pay(account(1), account(payee), usd(), Cent::from(10))
  415. .build();
  416. let _ = ledger.commit(transfer).await;
  417. }));
  418. }
  419. for h in handles {
  420. h.await.unwrap();
  421. }
  422. assert_projection_matches(&ledger, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 99]).await;
  423. }