| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334 |
- //! Balances: the authoritative live-posting sum, the everyday cached read, and
- //! the append-only cache points that keep that read fast (ADR-0012, ADR-0019).
- //!
- //! One concept, two computation strategies that must agree:
- //!
- //! - [`compute_balance`](Ledger::compute_balance) is **authoritative**: the sum
- //! of the live (active or reserved) postings for one `(account, asset)`, always
- //! recomputed from the source of truth. It is what validation reads, what the
- //! projector folds into snapshots, and what reconciliation checks against.
- //! - [`balance`](Ledger::balance) is the **everyday read**: the closest cache
- //! point (at or before now) plus the folded tail of transfers committed after
- //! its watermark.
- //!
- //! The module's core contract: summing every one of an account's transfer deltas
- //! equals its live-posting sum, so **`balance` always equals `compute_balance` at
- //! rest**. Cache points are a pure optimization that shortens the tail;
- //! correctness never depends on them, and reconciliation re-derives the
- //! authoritative value to check against a cache point.
- //!
- //! Balances are always computed in Rust with checked arithmetic and are never
- //! summed across subaccounts (ADR-0012).
- use std::collections::HashSet;
- use std::sync::Arc;
- use tracing::instrument;
- use kuatia_core::{AccountId, AssetId, Cent, PostingFilter, PostingId};
- use kuatia_storage::store::{EnvelopeRecord, Store, TransferQuery};
- use super::{Ledger, now_millis};
- use crate::error::LedgerError;
- /// A single subaccount's balance for one asset. Balances are always reported
- /// per subaccount and never summed across them (ADR-0012).
- #[derive(Clone, Copy, PartialEq, Eq, Debug)]
- pub struct SubAccountBalance {
- /// The subaccount this balance belongs to.
- pub account: AccountId,
- /// The balance of `account` for the queried asset.
- pub value: Cent,
- }
- // ---------------------------------------------------------------------------
- // Cache-point internals (ADR-0019): fold committed-transfer deltas onto a
- // snapshot. Private to this module; the read/reconcile entry points are on
- // `Ledger` below.
- // ---------------------------------------------------------------------------
- /// Fold the per-`(account, asset)` delta of a set of committed transfers,
- /// `Σ created(+) − Σ consumed(−)` restricted to postings owned by `account` in
- /// `asset`, and count how many such postings (credits + debits) were seen.
- /// Consumed postings are resolved from the immutable table (the envelope carries
- /// only their ids). All arithmetic is checked, in Rust.
- async fn fold_account_delta(
- store: &dyn Store,
- account: &AccountId,
- asset: &AssetId,
- records: &[EnvelopeRecord],
- ) -> Result<(Cent, u64), LedgerError> {
- let mut delta = Cent::ZERO;
- let mut count: u64 = 0;
- // Created side (credits): the envelope carries owner/asset/value directly.
- for record in records {
- for np in record.envelope.creates() {
- if np.owner == *account && np.asset == *asset {
- delta = delta.checked_add(np.value)?;
- count += 1;
- }
- }
- }
- // Consumed side (debits): gather every consumed id across the window, resolve
- // the postings in one batch, and subtract those owned by this (account, asset).
- let mut consumed_ids: Vec<PostingId> = Vec::new();
- let mut seen: HashSet<PostingId> = HashSet::new();
- for record in records {
- for id in record.envelope.consumes() {
- if seen.insert(*id) {
- consumed_ids.push(*id);
- }
- }
- }
- if !consumed_ids.is_empty() {
- for posting in store.get_postings(&consumed_ids).await? {
- if posting.owner == *account && posting.asset == *asset {
- delta = delta.checked_sub(posting.value)?;
- count += 1;
- }
- }
- }
- Ok((delta, count))
- }
- /// Load the committed transfers involving `account` with commit time in
- /// `[from_ts, to_ts)` (both optional), then fold their `(account, asset)` delta
- /// and credit/debit count. `from_ts == None` spans from the beginning;
- /// `to_ts == None` spans to the newest committed transfer.
- async fn fold_tail(
- store: &dyn Store,
- account: &AccountId,
- asset: &AssetId,
- from_ts: Option<i64>,
- to_ts: Option<i64>,
- ) -> Result<(Cent, u64), LedgerError> {
- let query = TransferQuery {
- account: Some(account.id),
- sub: Some(account.sub),
- from_ts,
- to_ts,
- ..Default::default()
- };
- let records = store.query_transfers(&query).await?.items;
- fold_account_delta(store, account, asset, &records).await
- }
- /// Append a fresh cache point for `(account, asset)`: fold the window since the
- /// closest cache point's watermark up to `now − grace` onto its snapshot, and
- /// append the result. Only appends when that window has at least `min_new`
- /// credits/debits, so a hot account that is read constantly does not append a
- /// near-duplicate row on every read (`min_new == 0` forces an append). Append-only
- /// and best effort; a stale or duplicate append is harmless because a read takes
- /// the closest-at-or-before cache point.
- ///
- /// `debounce_ms` is the storage-based single-flight guard. When the newest cache
- /// point already sits within `debounce_ms` below the target watermark, this
- /// returns before the fold, so a hot account read at high QPS does not fan out a
- /// full fold per read. The guard lives in the shared `balance_projection` rows,
- /// not in process memory, so it dedups across every ledger instance and survives
- /// a restart, and it needs no lease, lock, or CAS (ADR-0019). `debounce_ms == 0`
- /// disables it (the reconcile path forces an append regardless).
- async fn append_cache_point(
- store: &dyn Store,
- grace_ms: i64,
- min_new: u64,
- debounce_ms: i64,
- account: &AccountId,
- asset: &AssetId,
- ) -> Result<(), LedgerError> {
- let new_watermark = now_millis()?.saturating_sub(grace_ms);
- let closest = store
- .get_closest_balance_projection(account, asset, new_watermark)
- .await?;
- let (snapshot, from_ts) = match closest {
- // Storage-based debounce: a cache point within `debounce_ms` below the
- // target already shortens the tail enough, so skip the fold. This is what
- // collapses a per-read fan-out into at most one fold per debounce window.
- Some(p) if new_watermark.saturating_sub(p.watermark) < debounce_ms => return Ok(()),
- // A cache point already covers this watermark: nothing to add. (Also the
- // debounce == 0 stop, so an exact-or-newer watermark is never duplicated.)
- Some(p) if p.watermark >= new_watermark => return Ok(()),
- Some(p) => (p.balance, Some(p.watermark.saturating_add(1))),
- None => (Cent::ZERO, None),
- };
- let (fold, count) = fold_tail(
- store,
- account,
- asset,
- from_ts,
- Some(new_watermark.saturating_add(1)),
- )
- .await?;
- // Not enough new activity since the closest cache point to earn a new row.
- if count < min_new {
- return Ok(());
- }
- let balance = snapshot.checked_add(fold)?;
- store
- .append_balance_projection(account, asset, balance, new_watermark)
- .await?;
- Ok(())
- }
- impl Ledger {
- /// The authoritative balance: the sum of the live (active or reserved)
- /// postings for one `(account, asset)`, computed in Rust. This bypasses the
- /// cached projection and always recomputes from the source of truth, so it is
- /// what validation reads, what the projector folds into snapshots, and what
- /// reconciliation checks against. Cost is `O(live postings)`; prefer
- /// [`balance`](Ledger::balance) for the everyday read.
- #[instrument(skip(self), name = "ledger.compute_balance")]
- pub async fn compute_balance(
- &self,
- account: &AccountId,
- asset: &AssetId,
- ) -> Result<Cent, LedgerError> {
- let postings = self
- .store
- .get_postings_by_account(
- account.id,
- Some(account.sub),
- Some(asset),
- PostingFilter::Live,
- )
- .await?;
- Ok(Cent::checked_sum(postings.iter().map(|p| p.value))?)
- }
- /// The everyday balance read for one subaccount and asset (ADR-0019): the
- /// closest cache point (at or before now) plus the folded tail of transfers
- /// committed after its watermark. Always equal to
- /// [`compute_balance`](Ledger::compute_balance) at rest, and faster once a
- /// cache point keeps the tail short. With no cache point yet it returns the
- /// authoritative live-posting sum directly (rather than folding the whole
- /// history) and bootstraps a cache point in the background. Once enough
- /// credits/debits have accrued since the closest cache point, it also appends
- /// a new one in the background for later reads.
- #[instrument(skip(self), name = "ledger.balance")]
- pub async fn balance(&self, account: &AccountId, asset: &AssetId) -> Result<Cent, LedgerError> {
- let now = now_millis()?;
- let closest = self
- .store
- .get_closest_balance_projection(account, asset, now)
- .await?;
- match closest {
- Some(p) => {
- let (tail, count) = fold_tail(
- self.store(),
- account,
- asset,
- Some(p.watermark.saturating_add(1)),
- None,
- )
- .await?;
- if count >= self.snapshot_interval {
- self.spawn_append(*account, *asset);
- }
- Ok(p.balance.checked_add(tail)?)
- }
- // No cache point: the authoritative live sum is O(live postings),
- // cheaper than folding the whole history. Bootstrap a cache point in
- // the background (the append itself gates on `snapshot_interval`, so a
- // small account never actually appends) so later reads use the tail.
- None => {
- let balance = self.compute_balance(account, asset).await?;
- self.spawn_append(*account, *asset);
- Ok(balance)
- }
- }
- }
- /// Report the per-subaccount balances of a base account for one asset.
- ///
- /// One entry per non-closed subaccount, each read through the everyday cached
- /// [`balance`](Ledger::balance) so every balance read goes through one path.
- /// `sub == None` spans every subaccount of `account`'s base id; `Some(s)`
- /// restricts to that one. Balances are never summed across subaccounts
- /// (ADR-0012).
- #[instrument(skip(self), name = "ledger.balances")]
- pub async fn balances(
- &self,
- account: &AccountId,
- asset: &AssetId,
- sub: Option<i64>,
- ) -> Result<Vec<SubAccountBalance>, LedgerError> {
- let mut result = Vec::new();
- for subaccount in self.list_subaccounts(account).await? {
- if let Some(s) = sub
- && subaccount.sub != s
- {
- continue;
- }
- let value = self.balance(&subaccount, asset).await?;
- result.push(SubAccountBalance {
- account: subaccount,
- value,
- });
- }
- Ok(result)
- }
- /// List the non-closed subaccounts of a base account.
- ///
- /// This scans every account row and filters in memory, so it pays for
- /// subaccounts that were created and later closed (ADR-0012).
- #[instrument(skip(self), name = "ledger.list_subaccounts")]
- pub async fn list_subaccounts(
- &self,
- account: &AccountId,
- ) -> Result<Vec<AccountId>, LedgerError> {
- let base = account.id;
- let mut subs: Vec<AccountId> = self
- .store
- .list_accounts()
- .await?
- .into_iter()
- .filter(|a| a.id.id == base && !a.is_closed())
- .map(|a| a.id)
- .collect();
- subs.sort();
- Ok(subs)
- }
- /// Spawn a best-effort background append gated on `snapshot_interval` new
- /// credits/debits. Uses only the store handle and config, so it needs no
- /// `Arc<Self>` and can run from a `&self` read.
- ///
- /// Redundant spawns are cheap, not suppressed here: the storage-based debounce
- /// inside [`append_cache_point`] (keyed on the newest shared cache-point row,
- /// with `debounce_ms == grace`) returns before the fold when a recent cache
- /// point already exists, so a hot account read at high QPS does at most one
- /// fold per grace window, coordinated across every instance rather than in
- /// this process's memory.
- fn spawn_append(&self, account: AccountId, asset: AssetId) {
- let store = Arc::clone(&self.store);
- let grace = self.projection_grace_ms;
- let min_new = self.snapshot_interval;
- tokio::spawn(async move {
- if let Err(err) =
- append_cache_point(store.as_ref(), grace, min_new, grace, &account, &asset).await
- {
- // Best effort: a failed append only lengthens a later read's tail.
- // Log it so a projection that silently stops advancing is visible.
- tracing::warn!(?account, ?asset, error = %err, "balance projection append failed");
- }
- });
- }
- /// Append a cache point for one `(account, asset)` now (folding up to
- /// `now − grace`), unconditionally. The read path appends lazily in the
- /// background; this forces one (no debounce), exposed for tests and
- /// reconciliation. Append-only: a repeat within the same grace-adjusted
- /// millisecond is a no-op (the watermark is already covered), otherwise it
- /// adds a fresh row.
- pub async fn append_cache_point(
- &self,
- account: &AccountId,
- asset: &AssetId,
- ) -> Result<(), LedgerError> {
- append_cache_point(self.store(), self.projection_grace_ms, 0, 0, account, asset).await
- }
- }
|