balance.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. //! Balances: the authoritative live-posting sum, the everyday cached read, and
  2. //! the append-only cache points that keep that read fast (ADR-0012, ADR-0019).
  3. //!
  4. //! One concept, two computation strategies that must agree:
  5. //!
  6. //! - [`compute_balance`](Ledger::compute_balance) is **authoritative**: the sum
  7. //! of the live (active or reserved) postings for one `(account, asset)`, always
  8. //! recomputed from the source of truth. It is what validation reads, what the
  9. //! projector folds into snapshots, and what reconciliation checks against.
  10. //! - [`balance`](Ledger::balance) is the **everyday read**: the closest cache
  11. //! point (at or before now) plus the folded tail of transfers committed after
  12. //! its watermark.
  13. //!
  14. //! The module's core contract: summing every one of an account's transfer deltas
  15. //! equals its live-posting sum, so **`balance` always equals `compute_balance` at
  16. //! rest**. Cache points are a pure optimization that shortens the tail;
  17. //! correctness never depends on them, and reconciliation re-derives the
  18. //! authoritative value to check against a cache point.
  19. //!
  20. //! Balances are always computed in Rust with checked arithmetic and are never
  21. //! summed across subaccounts (ADR-0012).
  22. use std::collections::HashSet;
  23. use std::sync::Arc;
  24. use tracing::instrument;
  25. use kuatia_core::{AccountId, AssetId, Cent, PostingFilter, PostingId};
  26. use kuatia_storage::store::{EnvelopeRecord, Store, TransferQuery};
  27. use super::{Ledger, now_millis};
  28. use crate::error::LedgerError;
  29. /// A single subaccount's balance for one asset. Balances are always reported
  30. /// per subaccount and never summed across them (ADR-0012).
  31. #[derive(Clone, Copy, PartialEq, Eq, Debug)]
  32. pub struct SubAccountBalance {
  33. /// The subaccount this balance belongs to.
  34. pub account: AccountId,
  35. /// The balance of `account` for the queried asset.
  36. pub value: Cent,
  37. }
  38. // ---------------------------------------------------------------------------
  39. // Cache-point internals (ADR-0019): fold committed-transfer deltas onto a
  40. // snapshot. Private to this module; the read/reconcile entry points are on
  41. // `Ledger` below.
  42. // ---------------------------------------------------------------------------
  43. /// Fold the per-`(account, asset)` delta of a set of committed transfers,
  44. /// `Σ created(+) − Σ consumed(−)` restricted to postings owned by `account` in
  45. /// `asset`, and count how many such postings (credits + debits) were seen.
  46. /// Consumed postings are resolved from the immutable table (the envelope carries
  47. /// only their ids). All arithmetic is checked, in Rust.
  48. async fn fold_account_delta(
  49. store: &dyn Store,
  50. account: &AccountId,
  51. asset: &AssetId,
  52. records: &[EnvelopeRecord],
  53. ) -> Result<(Cent, u64), LedgerError> {
  54. let mut delta = Cent::ZERO;
  55. let mut count: u64 = 0;
  56. // Created side (credits): the envelope carries owner/asset/value directly.
  57. for record in records {
  58. for np in record.envelope.creates() {
  59. if np.owner == *account && np.asset == *asset {
  60. delta = delta.checked_add(np.value)?;
  61. count += 1;
  62. }
  63. }
  64. }
  65. // Consumed side (debits): gather every consumed id across the window, resolve
  66. // the postings in one batch, and subtract those owned by this (account, asset).
  67. let mut consumed_ids: Vec<PostingId> = Vec::new();
  68. let mut seen: HashSet<PostingId> = HashSet::new();
  69. for record in records {
  70. for id in record.envelope.consumes() {
  71. if seen.insert(*id) {
  72. consumed_ids.push(*id);
  73. }
  74. }
  75. }
  76. if !consumed_ids.is_empty() {
  77. for posting in store.get_postings(&consumed_ids).await? {
  78. if posting.owner == *account && posting.asset == *asset {
  79. delta = delta.checked_sub(posting.value)?;
  80. count += 1;
  81. }
  82. }
  83. }
  84. Ok((delta, count))
  85. }
  86. /// Load the committed transfers involving `account` with commit time in
  87. /// `[from_ts, to_ts)` (both optional), then fold their `(account, asset)` delta
  88. /// and credit/debit count. `from_ts == None` spans from the beginning;
  89. /// `to_ts == None` spans to the newest committed transfer.
  90. async fn fold_tail(
  91. store: &dyn Store,
  92. account: &AccountId,
  93. asset: &AssetId,
  94. from_ts: Option<i64>,
  95. to_ts: Option<i64>,
  96. ) -> Result<(Cent, u64), LedgerError> {
  97. let query = TransferQuery {
  98. account: Some(account.id),
  99. sub: Some(account.sub),
  100. from_ts,
  101. to_ts,
  102. ..Default::default()
  103. };
  104. let records = store.query_transfers(&query).await?.items;
  105. fold_account_delta(store, account, asset, &records).await
  106. }
  107. /// Append a fresh cache point for `(account, asset)`: fold the window since the
  108. /// closest cache point's watermark up to `now − grace` onto its snapshot, and
  109. /// append the result. Only appends when that window has at least `min_new`
  110. /// credits/debits, so a hot account that is read constantly does not append a
  111. /// near-duplicate row on every read (`min_new == 0` forces an append). Append-only
  112. /// and best effort; a stale or duplicate append is harmless because a read takes
  113. /// the closest-at-or-before cache point.
  114. ///
  115. /// `debounce_ms` is the storage-based single-flight guard. When the newest cache
  116. /// point already sits within `debounce_ms` below the target watermark, this
  117. /// returns before the fold, so a hot account read at high QPS does not fan out a
  118. /// full fold per read. The guard lives in the shared `balance_projection` rows,
  119. /// not in process memory, so it dedups across every ledger instance and survives
  120. /// a restart, and it needs no lease, lock, or CAS (ADR-0019). `debounce_ms == 0`
  121. /// disables it (the reconcile path forces an append regardless).
  122. async fn append_cache_point(
  123. store: &dyn Store,
  124. grace_ms: i64,
  125. min_new: u64,
  126. debounce_ms: i64,
  127. account: &AccountId,
  128. asset: &AssetId,
  129. ) -> Result<(), LedgerError> {
  130. let new_watermark = now_millis()?.saturating_sub(grace_ms);
  131. let closest = store
  132. .get_closest_balance_projection(account, asset, new_watermark)
  133. .await?;
  134. let (snapshot, from_ts) = match closest {
  135. // Storage-based debounce: a cache point within `debounce_ms` below the
  136. // target already shortens the tail enough, so skip the fold. This is what
  137. // collapses a per-read fan-out into at most one fold per debounce window.
  138. Some(p) if new_watermark.saturating_sub(p.watermark) < debounce_ms => return Ok(()),
  139. // A cache point already covers this watermark: nothing to add. (Also the
  140. // debounce == 0 stop, so an exact-or-newer watermark is never duplicated.)
  141. Some(p) if p.watermark >= new_watermark => return Ok(()),
  142. Some(p) => (p.balance, Some(p.watermark.saturating_add(1))),
  143. None => (Cent::ZERO, None),
  144. };
  145. let (fold, count) = fold_tail(
  146. store,
  147. account,
  148. asset,
  149. from_ts,
  150. Some(new_watermark.saturating_add(1)),
  151. )
  152. .await?;
  153. // Not enough new activity since the closest cache point to earn a new row.
  154. if count < min_new {
  155. return Ok(());
  156. }
  157. let balance = snapshot.checked_add(fold)?;
  158. store
  159. .append_balance_projection(account, asset, balance, new_watermark)
  160. .await?;
  161. Ok(())
  162. }
  163. impl Ledger {
  164. /// The authoritative balance: the sum of the live (active or reserved)
  165. /// postings for one `(account, asset)`, computed in Rust. This bypasses the
  166. /// cached projection and always recomputes from the source of truth, so it is
  167. /// what validation reads, what the projector folds into snapshots, and what
  168. /// reconciliation checks against. Cost is `O(live postings)`; prefer
  169. /// [`balance`](Ledger::balance) for the everyday read.
  170. #[instrument(skip(self), name = "ledger.compute_balance")]
  171. pub async fn compute_balance(
  172. &self,
  173. account: &AccountId,
  174. asset: &AssetId,
  175. ) -> Result<Cent, LedgerError> {
  176. let postings = self
  177. .store
  178. .get_postings_by_account(
  179. account.id,
  180. Some(account.sub),
  181. Some(asset),
  182. PostingFilter::Live,
  183. )
  184. .await?;
  185. Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
  186. }
  187. /// The everyday balance read for one subaccount and asset (ADR-0019): the
  188. /// closest cache point (at or before now) plus the folded tail of transfers
  189. /// committed after its watermark. Always equal to
  190. /// [`compute_balance`](Ledger::compute_balance) at rest, and faster once a
  191. /// cache point keeps the tail short. With no cache point yet it returns the
  192. /// authoritative live-posting sum directly (rather than folding the whole
  193. /// history) and bootstraps a cache point in the background. Once enough
  194. /// credits/debits have accrued since the closest cache point, it also appends
  195. /// a new one in the background for later reads.
  196. #[instrument(skip(self), name = "ledger.balance")]
  197. pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
  198. let now = now_millis()?;
  199. let closest = self
  200. .store
  201. .get_closest_balance_projection(account, asset, now)
  202. .await?;
  203. match closest {
  204. Some(p) => {
  205. let (tail, count) = fold_tail(
  206. self.store(),
  207. account,
  208. asset,
  209. Some(p.watermark.saturating_add(1)),
  210. None,
  211. )
  212. .await?;
  213. if count >= self.snapshot_interval {
  214. self.spawn_append(*account, *asset);
  215. }
  216. Ok(p.balance.checked_add(tail)?)
  217. }
  218. // No cache point: the authoritative live sum is O(live postings),
  219. // cheaper than folding the whole history. Bootstrap a cache point in
  220. // the background (the append itself gates on `snapshot_interval`, so a
  221. // small account never actually appends) so later reads use the tail.
  222. None => {
  223. let balance = self.compute_balance(account, asset).await?;
  224. self.spawn_append(*account, *asset);
  225. Ok(balance)
  226. }
  227. }
  228. }
  229. /// Report the per-subaccount balances of a base account for one asset.
  230. ///
  231. /// One entry per non-closed subaccount, each read through the everyday cached
  232. /// [`balance`](Ledger::balance) so every balance read goes through one path.
  233. /// `sub == None` spans every subaccount of `account`'s base id; `Some(s)`
  234. /// restricts to that one. Balances are never summed across subaccounts
  235. /// (ADR-0012).
  236. #[instrument(skip(self), name = "ledger.balances")]
  237. pub async fn balances(
  238. &self,
  239. account: &AccountId,
  240. asset: &AssetId,
  241. sub: Option<i64>,
  242. ) -> Result<Vec<SubAccountBalance>, LedgerError> {
  243. let mut result = Vec::new();
  244. for subaccount in self.list_subaccounts(account).await? {
  245. if let Some(s) = sub
  246. && subaccount.sub != s
  247. {
  248. continue;
  249. }
  250. let value = self.balance(&subaccount, asset).await?;
  251. result.push(SubAccountBalance {
  252. account: subaccount,
  253. value,
  254. });
  255. }
  256. Ok(result)
  257. }
  258. /// List the non-closed subaccounts of a base account.
  259. ///
  260. /// This scans every account row and filters in memory, so it pays for
  261. /// subaccounts that were created and later closed (ADR-0012).
  262. #[instrument(skip(self), name = "ledger.list_subaccounts")]
  263. pub async fn list_subaccounts(
  264. &self,
  265. account: &AccountId,
  266. ) -> Result<Vec<AccountId>, LedgerError> {
  267. let base = account.id;
  268. let mut subs: Vec<AccountId> = self
  269. .store
  270. .list_accounts()
  271. .await?
  272. .into_iter()
  273. .filter(|a| a.id.id == base && !a.is_closed())
  274. .map(|a| a.id)
  275. .collect();
  276. subs.sort();
  277. Ok(subs)
  278. }
  279. /// Spawn a best-effort background append gated on `snapshot_interval` new
  280. /// credits/debits. Uses only the store handle and config, so it needs no
  281. /// `Arc<Self>` and can run from a `&self` read.
  282. ///
  283. /// Redundant spawns are cheap, not suppressed here: the storage-based debounce
  284. /// inside [`append_cache_point`] (keyed on the newest shared cache-point row,
  285. /// with `debounce_ms == grace`) returns before the fold when a recent cache
  286. /// point already exists, so a hot account read at high QPS does at most one
  287. /// fold per grace window, coordinated across every instance rather than in
  288. /// this process's memory.
  289. fn spawn_append(&self, account: AccountId, asset: AssetId) {
  290. let store = Arc::clone(&self.store);
  291. let grace = self.projection_grace_ms;
  292. let min_new = self.snapshot_interval;
  293. tokio::spawn(async move {
  294. if let Err(err) =
  295. append_cache_point(store.as_ref(), grace, min_new, grace, &account, &asset).await
  296. {
  297. // Best effort: a failed append only lengthens a later read's tail.
  298. // Log it so a projection that silently stops advancing is visible.
  299. tracing::warn!(?account, ?asset, error = %err, "balance projection append failed");
  300. }
  301. });
  302. }
  303. /// Append a cache point for one `(account, asset)` now (folding up to
  304. /// `now − grace`), unconditionally. The read path appends lazily in the
  305. /// background; this forces one (no debounce), exposed for tests and
  306. /// reconciliation. Append-only: a repeat within the same grace-adjusted
  307. /// millisecond is a no-op (the watermark is already covered), otherwise it
  308. /// adds a fresh row.
  309. pub async fn append_cache_point(
  310. &self,
  311. account: &AccountId,
  312. asset: &AssetId,
  313. ) -> Result<(), LedgerError> {
  314. append_cache_point(self.store(), self.projection_grace_ms, 0, 0, account, asset).await
  315. }
  316. }