projection.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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. async fn pay(ledger: &Arc<Ledger>, from: i64, to: i64, amount: i64) {
  40. let transfer = TransferBuilder::new()
  41. .pay(account(from), account(to), usd(), Cent::from(amount))
  42. .build();
  43. ledger.commit(transfer).await.unwrap();
  44. }
  45. /// Assert the projection-aware read equals the authoritative live-posting sum
  46. /// for every account, at every asset it might hold.
  47. async fn assert_projection_matches(ledger: &Arc<Ledger>, ids: &[i64]) {
  48. for &id in ids {
  49. let authoritative = ledger.compute_balance(&account(id), &usd()).await.unwrap();
  50. let projected = ledger.balance(&account(id), &usd()).await.unwrap();
  51. assert_eq!(
  52. projected, authoritative,
  53. "projected balance for account {id} diverged from the live sum"
  54. );
  55. }
  56. }
  57. /// With no projector run at all (every projection absent, so the read folds the
  58. /// whole history), the projection-aware read still equals the live sum through
  59. /// deposits, change-making pays, and an overdraft offset posting.
  60. #[tokio::test]
  61. async fn projected_balance_matches_live_sum_without_refresh() {
  62. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  63. no_overdraft(&ledger, 1).await;
  64. no_overdraft(&ledger, 2).await;
  65. overdraft(&ledger, 10).await;
  66. overdraft(&ledger, 99).await;
  67. deposit(&ledger, 1, 1000).await;
  68. // Several pays fragment account 1 into many change postings.
  69. pay(&ledger, 1, 2, 150).await;
  70. pay(&ledger, 1, 2, 70).await;
  71. pay(&ledger, 1, 2, 30).await;
  72. pay(&ledger, 2, 1, 40).await;
  73. // Overdraft: account 10 (empty balance) pays into a negative offset posting.
  74. pay(&ledger, 10, 2, 500).await;
  75. assert_projection_matches(&ledger, &[1, 2, 10, 99]).await;
  76. }
  77. /// Appending a cache point stores the balance directly, and the read still equals
  78. /// the live sum. Appending only after all commits keeps this deterministic: grace
  79. /// 0 puts the watermark at "now", so every committed transfer folds into the cache
  80. /// point with an empty tail.
  81. #[tokio::test]
  82. async fn append_cache_point_snapshots_balance_and_preserves_answer() {
  83. let ledger = Arc::new(Ledger::new(InMemoryStore::new()).with_projection_grace_ms(0));
  84. no_overdraft(&ledger, 1).await;
  85. no_overdraft(&ledger, 2).await;
  86. overdraft(&ledger, 99).await;
  87. deposit(&ledger, 1, 1000).await;
  88. pay(&ledger, 1, 2, 250).await;
  89. pay(&ledger, 1, 2, 100).await;
  90. ledger
  91. .append_cache_point(&account(1), &usd())
  92. .await
  93. .unwrap();
  94. // The cache point holds account 1's balance (1000 - 250 - 100) directly.
  95. let cache_point = ledger
  96. .store()
  97. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  98. .await
  99. .unwrap()
  100. .expect("a cache point exists after append");
  101. assert_eq!(cache_point.balance, Cent::from(650));
  102. assert_eq!(
  103. ledger.balance(&account(1), &usd()).await.unwrap(),
  104. Cent::from(650)
  105. );
  106. assert_projection_matches(&ledger, &[1, 2]).await;
  107. }
  108. /// Commit never writes a cache point: after commits with no read, none exists.
  109. #[tokio::test]
  110. async fn commit_does_not_write_cache_point() {
  111. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  112. no_overdraft(&ledger, 1).await;
  113. no_overdraft(&ledger, 2).await;
  114. overdraft(&ledger, 99).await;
  115. deposit(&ledger, 1, 1000).await;
  116. pay(&ledger, 1, 2, 250).await;
  117. // No read has happened, so the lazy append never fired.
  118. assert!(
  119. ledger
  120. .store()
  121. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  122. .await
  123. .unwrap()
  124. .is_none()
  125. );
  126. }
  127. /// A read appends a cache point once `snapshot_interval` credits/debits have
  128. /// accrued (the append is spawned in the background; on the current-thread test
  129. /// runtime it runs when we yield).
  130. #[tokio::test]
  131. async fn read_appends_cache_point_after_interval() {
  132. let ledger = Arc::new(
  133. Ledger::new(InMemoryStore::new())
  134. .with_projection_grace_ms(0)
  135. .with_snapshot_interval(1),
  136. );
  137. no_overdraft(&ledger, 1).await;
  138. no_overdraft(&ledger, 2).await;
  139. overdraft(&ledger, 99).await;
  140. deposit(&ledger, 1, 1000).await;
  141. pay(&ledger, 1, 2, 250).await;
  142. // This read folds >= 1 credit/debit for account 1, so it spawns an append.
  143. let _ = ledger.balance(&account(1), &usd()).await.unwrap();
  144. // Let the background append run, then confirm a cache point exists.
  145. let mut appeared = false;
  146. for _ in 0..1000 {
  147. tokio::task::yield_now().await;
  148. if ledger
  149. .store()
  150. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  151. .await
  152. .unwrap()
  153. .is_some()
  154. {
  155. appeared = true;
  156. break;
  157. }
  158. }
  159. assert!(
  160. appeared,
  161. "a read past the interval should append a cache point"
  162. );
  163. assert_eq!(
  164. ledger.balance(&account(1), &usd()).await.unwrap(),
  165. ledger.compute_balance(&account(1), &usd()).await.unwrap()
  166. );
  167. }
  168. /// A read below the interval never appends a cache point: the background append
  169. /// gates on new credits/debits, so a low-activity account accrues no rows (this
  170. /// is what keeps a hot, frequently-read account from appending near-duplicates).
  171. #[tokio::test]
  172. async fn read_below_interval_appends_nothing() {
  173. let ledger = Arc::new(
  174. Ledger::new(InMemoryStore::new())
  175. .with_projection_grace_ms(0)
  176. .with_snapshot_interval(1_000),
  177. );
  178. no_overdraft(&ledger, 1).await;
  179. no_overdraft(&ledger, 2).await;
  180. overdraft(&ledger, 99).await;
  181. deposit(&ledger, 1, 1000).await;
  182. pay(&ledger, 1, 2, 250).await;
  183. // A handful of credits/debits, far below the 1000 interval.
  184. let bal = ledger.balance(&account(1), &usd()).await.unwrap();
  185. assert_eq!(
  186. bal,
  187. ledger.compute_balance(&account(1), &usd()).await.unwrap()
  188. );
  189. // Even after the background task has every chance to run, no cache point was
  190. // appended (the append gates on >= interval new credits/debits).
  191. for _ in 0..1000 {
  192. tokio::task::yield_now().await;
  193. }
  194. assert!(
  195. ledger
  196. .store()
  197. .get_closest_balance_projection(&account(1), &usd(), i64::MAX)
  198. .await
  199. .unwrap()
  200. .is_none(),
  201. "a below-interval read must not append a cache point"
  202. );
  203. }
  204. /// Deterministic xorshift64 PRNG so the property test is reproducible.
  205. struct Rng(u64);
  206. impl Rng {
  207. fn next(&mut self) -> u64 {
  208. let mut x = self.0;
  209. x ^= x << 13;
  210. x ^= x >> 7;
  211. x ^= x << 17;
  212. self.0 = x;
  213. x
  214. }
  215. fn below(&mut self, n: u64) -> u64 {
  216. self.next() % n
  217. }
  218. }
  219. /// Property test: across a long random sequence of every UTXO-shaped operation
  220. /// (deposits, change-making pays, overdraft offsets, multi-asset, subaccounts,
  221. /// and reversals), the projection-aware read equals the authoritative live-posting
  222. /// sum for every (account, asset) after every step. This is the empirical form of
  223. /// the telescoping argument: whole-posting spends plus change-as-new-posting make
  224. /// `snapshot + tail` fold exactly to the live set, in every shape the ledger can
  225. /// produce.
  226. #[tokio::test]
  227. async fn projection_matches_live_sum_across_random_utxo_history() {
  228. // A low interval so reads append cache points throughout the run, exercising
  229. // the append-only cache points and closest-at-or-before selection. The
  230. // default grace keeps each watermark safely in the past (so the tail folds
  231. // every commit and no same-millisecond commit races an append); the invariant
  232. // holds regardless of cache-point state.
  233. let ledger = Arc::new(Ledger::new(InMemoryStore::new()).with_snapshot_interval(4));
  234. // Accounts under test, including two subaccounts and both overdraft kinds.
  235. let no_overdraft_ids = [
  236. AccountId::new(1),
  237. AccountId::new(2),
  238. AccountId::new(3),
  239. AccountId::with_sub(1, 7),
  240. ];
  241. let overdraft_ids = [
  242. AccountId::new(10),
  243. AccountId::new(11),
  244. AccountId::with_sub(11, 3),
  245. ];
  246. for id in no_overdraft_ids {
  247. ledger
  248. .store()
  249. .create_account(Account::debit_must_not_exceed_credit(id))
  250. .await
  251. .unwrap();
  252. }
  253. for id in overdraft_ids {
  254. ledger
  255. .store()
  256. .create_account(Account::new(id))
  257. .await
  258. .unwrap();
  259. }
  260. let ext = external();
  261. ledger
  262. .store()
  263. .create_account(Account::new(ext))
  264. .await
  265. .unwrap();
  266. let accounts: Vec<AccountId> = no_overdraft_ids
  267. .iter()
  268. .chain(overdraft_ids.iter())
  269. .copied()
  270. .collect();
  271. let assets = [AssetId::new(1), AssetId::new(2)];
  272. let mut rng = Rng(0x9e3779b97f4a7c15);
  273. let mut receipts: Vec<EnvelopeId> = Vec::new();
  274. for _ in 0..300 {
  275. let asset = assets[rng.below(assets.len() as u64) as usize];
  276. match rng.below(5) {
  277. // Deposit into a random account.
  278. 0 => {
  279. let to = accounts[rng.below(accounts.len() as u64) as usize];
  280. let amount = 1 + rng.below(500) as i64;
  281. let t = TransferBuilder::new()
  282. .deposit(to, asset, Cent::from(amount), ext)
  283. .unwrap()
  284. .build();
  285. if let Ok(r) = ledger.commit(t).await {
  286. receipts.push(r.transfer_id);
  287. }
  288. }
  289. // Withdraw from a random account to the boundary.
  290. 1 => {
  291. let from = accounts[rng.below(accounts.len() as u64) as usize];
  292. let amount = 1 + rng.below(200) as i64;
  293. let t = TransferBuilder::new()
  294. .withdraw(from, asset, Cent::from(amount), ext)
  295. .build();
  296. if let Ok(r) = ledger.commit(t).await {
  297. receipts.push(r.transfer_id);
  298. }
  299. }
  300. // Reverse a previously committed transfer.
  301. 2 if !receipts.is_empty() => {
  302. let id = receipts[rng.below(receipts.len() as u64) as usize];
  303. if let Ok(r) = ledger.reverse(&id).await {
  304. receipts.push(r.transfer_id);
  305. }
  306. }
  307. // Pay between two random accounts (change / overdraft / cross-subaccount).
  308. _ => {
  309. let from = accounts[rng.below(accounts.len() as u64) as usize];
  310. let to = accounts[rng.below(accounts.len() as u64) as usize];
  311. if from == to {
  312. continue;
  313. }
  314. let amount = 1 + rng.below(300) as i64;
  315. let t = TransferBuilder::new()
  316. .pay(from, to, asset, Cent::from(amount))
  317. .build();
  318. if let Ok(r) = ledger.commit(t).await {
  319. receipts.push(r.transfer_id);
  320. }
  321. }
  322. }
  323. // Invariant: at rest after every step, the projection-aware read equals
  324. // the authoritative live-posting sum for every (account, asset).
  325. for account in accounts.iter().chain(std::iter::once(&ext)) {
  326. for asset in &assets {
  327. let authoritative = ledger.compute_balance(account, asset).await.unwrap();
  328. let projected = ledger.balance(account, asset).await.unwrap();
  329. assert_eq!(
  330. projected, authoritative,
  331. "projected != live sum for {account:?} / {asset:?}"
  332. );
  333. }
  334. }
  335. }
  336. }
  337. /// Concurrent commits from one funded account: after the dust settles, the
  338. /// projection-aware read agrees with the live sum for every participant.
  339. #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
  340. async fn projection_matches_under_concurrent_commits() {
  341. let ledger = Arc::new(Ledger::new(InMemoryStore::new()));
  342. no_overdraft(&ledger, 1).await;
  343. for id in 2..=9 {
  344. no_overdraft(&ledger, id).await;
  345. }
  346. overdraft(&ledger, 99).await;
  347. deposit(&ledger, 1, 1000).await;
  348. let mut handles = Vec::new();
  349. for payee in 2..=9 {
  350. let ledger = Arc::clone(&ledger);
  351. handles.push(tokio::spawn(async move {
  352. let transfer = TransferBuilder::new()
  353. .pay(account(1), account(payee), usd(), Cent::from(10))
  354. .build();
  355. let _ = ledger.commit(transfer).await;
  356. }));
  357. }
  358. for h in handles {
  359. h.await.unwrap();
  360. }
  361. assert_projection_matches(&ledger, &[1, 2, 3, 4, 5, 6, 7, 8, 9, 99]).await;
  362. }