mod rows;
use std::collections::{BTreeMap, BTreeSet};
use std::ops::Bound;
use time::Date;
use super::{
AccountView, BalanceKey, ClearingView, EntryMeta, EntryView, Header, LatestTerms, Movement,
PeriodView, Slice, StateView, Terms, Versioned, Window,
};
use crate::core::audit::ReplayLeaf;
use crate::core::holds::{HoldEffect, HoldView};
use crate::core::writeset::{AccountTerm, IndexRow, MemoPlain, Outstanding, SealReceipt, WriteSet};
use crate::log::{BalanceTree, Tree, TreeHead, leaf_hash};
use crate::model::{
AccountHandle, AccountKind, AccountPath, AccountRegistration, Amount, Balance, BalanceLimit,
Currency, Dimensions, EntryId, Fingerprint, IdempotencyKey, Label, Layer, LedgerId,
LedgerPolicy, PeriodId, PostingRef, Record, Side,
};
use crate::store::{AccountRow, Erasure, PostingLine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ApplyError {
Stale,
Inconsistent,
}
#[derive(Debug, Clone)]
pub(crate) struct MemState<const P: u8> {
ledger: LedgerId,
tree: Tree,
leaves: Vec<StoredLeaf>,
entries: u64,
keys: BTreeMap<IdempotencyKey, EntryMeta>,
records: BTreeMap<EntryId, EntryView<P>>,
reversals: BTreeMap<EntryId, EntryId>,
accounts: Vec<AccountState>,
paths: BTreeMap<AccountPath, AccountHandle>,
currencies: BTreeMap<Currency, Vec<Versioned<u8>>>,
policies: Vec<Versioned<LedgerPolicy>>,
periods: BTreeMap<Date, PeriodView<P>>,
period_starts: BTreeMap<PeriodId, Date>,
watermark: Option<Date>,
balances: BTreeMap<BalanceKey, Balance<P>>,
postings: BTreeMap<(Date, u64, u16), Posted<P>>,
clearings: BTreeMap<u64, ClearingView<P>>,
clearing_keys: BTreeMap<IdempotencyKey, u64>,
applied: BTreeMap<PostingRef, Amount<P>>,
lines: BTreeMap<(BalanceKey, u64, u16), Line<P>>,
clearing_refs: BTreeSet<(BalanceKey, u64)>,
holds: BTreeMap<EntryId, HoldView<P>>,
due: BTreeMap<(Date, u64), EntryId>,
open_holds: BTreeMap<(AccountHandle, u64), EntryId>,
outstanding: BTreeMap<(AccountHandle, Currency), Outstanding<P>>,
by_booking: BTreeSet<(BalanceKey, Date, u64, u16)>,
seal_trees: BTreeMap<(PeriodId, Currency, Layer), BalanceTree<P>>,
}
#[derive(Debug, Clone)]
pub(crate) struct Line<const P: u8> {
pub(crate) entry: EntryId,
pub(crate) side: Side,
pub(crate) amount: Amount<P>,
pub(crate) booking: Date,
pub(crate) value: Date,
pub(crate) dimensions: Dimensions,
pub(crate) balance_after: Balance<P>,
}
#[derive(Debug, Clone)]
struct StoredLeaf {
bytes: Vec<u8>,
memo: Option<MemoPlain>,
fingerprint: Option<Fingerprint>,
}
#[derive(Debug, Clone)]
struct AccountState {
registration: AccountRegistration,
position: u64,
records: Vec<u64>,
windows: Vec<Versioned<Window>>,
limits: Vec<Versioned<BalanceLimit>>,
classes: Vec<Versioned<Option<Label>>>,
is_leaf: bool,
last_booked: Option<Date>,
}
#[derive(Debug, Clone)]
struct Posted<const P: u8> {
key: BalanceKey,
side: Side,
amount: Amount<P>,
dimensions: Dimensions,
}
fn in_force<T: Clone>(versions: &[Versioned<T>], on: Date) -> Option<T> {
let count = versions.partition_point(|version| version.effective <= on);
versions
.get(count.checked_sub(1)?)
.map(|version| version.value.clone())
}
impl AccountState {
fn view(&self) -> Option<AccountView> {
Some(AccountView {
registration: self.registration.clone(),
latest: LatestTerms {
window: self.windows.last()?.clone(),
limit: self.limits.last()?.clone(),
class: self.classes.last()?.clone(),
},
is_leaf: self.is_leaf,
last_booked: self.last_booked,
})
}
}
impl<const P: u8> MemState<P> {
pub(crate) const fn new(ledger: LedgerId) -> Self {
Self {
ledger,
tree: Tree::new(),
leaves: Vec::new(),
entries: 0,
keys: BTreeMap::new(),
records: BTreeMap::new(),
reversals: BTreeMap::new(),
accounts: Vec::new(),
paths: BTreeMap::new(),
currencies: BTreeMap::new(),
policies: Vec::new(),
periods: BTreeMap::new(),
period_starts: BTreeMap::new(),
watermark: None,
balances: BTreeMap::new(),
postings: BTreeMap::new(),
clearings: BTreeMap::new(),
clearing_keys: BTreeMap::new(),
applied: BTreeMap::new(),
lines: BTreeMap::new(),
clearing_refs: BTreeSet::new(),
holds: BTreeMap::new(),
due: BTreeMap::new(),
open_holds: BTreeMap::new(),
outstanding: BTreeMap::new(),
by_booking: BTreeSet::new(),
seal_trees: BTreeMap::new(),
}
}
pub(crate) const fn ledger(&self) -> &LedgerId {
&self.ledger
}
pub(crate) fn head(&self) -> TreeHead {
self.tree.head()
}
pub(crate) fn leaves(&self) -> impl Iterator<Item = ReplayLeaf<'_>> {
(0u64..)
.zip(&self.leaves)
.map(|(position, leaf)| ReplayLeaf {
position,
bytes: &leaf.bytes,
memo: leaf.memo.as_ref(),
fingerprint: leaf.fingerprint,
})
}
pub(crate) fn leaf(&self, position: u64) -> Option<ReplayLeaf<'_>> {
let leaf = self.leaves.get(usize::try_from(position).ok()?)?;
Some(ReplayLeaf {
position,
bytes: &leaf.bytes,
memo: leaf.memo.as_ref(),
fingerprint: leaf.fingerprint,
})
}
pub(crate) const fn tree(&self) -> &Tree {
&self.tree
}
pub(crate) fn erase_memo(&mut self, id: EntryId) -> Erasure {
let Some(view) = self.records.get(&id) else {
return Erasure::UnknownEntry;
};
if view.record.parts().memo.is_none() {
return Erasure::NoMemo;
}
let key = view.record.parts().key.clone();
let Some(leaf) = usize::try_from(view.position)
.ok()
.and_then(|index| self.leaves.get_mut(index))
else {
return Erasure::UnknownEntry;
};
if leaf.memo.take().is_none() {
return Erasure::AlreadyErased;
}
leaf.fingerprint = None;
if let Some(meta) = self.keys.get_mut(&key) {
meta.fingerprint = None;
}
Erasure::Erased
}
fn posting_key(&self, posting: PostingRef) -> Option<BalanceKey> {
let record = &self.records.get(&posting.entry)?.record;
let row = record.parts().postings.get(usize::from(posting.index))?;
Some(BalanceKey {
handle: row.handle,
currency: row.currency,
layer: row.layer,
})
}
pub(crate) fn lines(
&self,
key: BalanceKey,
positions: core::ops::Range<u64>,
) -> impl DoubleEndedIterator<Item = ((u64, u16), &Line<P>)> {
let (start, end) = (positions.start, positions.end.max(positions.start));
self.lines
.range((key, start, 0)..(key, end, 0))
.map(|((_, position, index), line)| ((*position, *index), line))
}
pub(crate) fn line(&self, key: BalanceKey, position: u64, index: u16) -> Option<&Line<P>> {
self.lines.get(&(key, position, index))
}
pub(crate) fn posting_line(
&self,
(position, index): (u64, u16),
line: &Line<P>,
) -> PostingLine<P> {
PostingLine {
position,
index,
entry: line.entry,
side: line.side,
amount: line.amount,
booking: line.booking,
value: line.value,
dimensions: line.dimensions.clone(),
balance_after: line.balance_after,
cleared: StateView::applied(self, PostingRef::at(line.entry, index)),
}
}
pub(crate) fn balance_at(&self, key: BalanceKey, position: u64) -> Balance<P> {
self.lines(key, 0..position)
.next_back()
.map_or(Balance::ZERO, |(_, line)| line.balance_after)
}
pub(crate) fn clearing_refs(
&self,
key: BalanceKey,
positions: core::ops::Range<u64>,
) -> impl Iterator<Item = u64> {
let (start, end) = (positions.start, positions.end.max(positions.start));
self.clearing_refs
.range((key, start)..(key, end))
.map(|(_, position)| *position)
}
pub(crate) fn holds_due(
&self,
on_or_before: Date,
after: Option<(Date, u64)>,
) -> impl Iterator<Item = &HoldView<P>> {
let start = after.map_or(Bound::Unbounded, Bound::Excluded);
self.due
.range((start, Bound::Included((on_or_before, u64::MAX))))
.filter_map(|(_, id)| self.holds.get(id))
}
#[cfg(feature = "testing")]
pub(crate) fn hold_rows(&self) -> Vec<crate::store::HoldRow<P>> {
let mut rows: Vec<crate::store::HoldRow<P>> =
self.holds.values().map(HoldView::row).collect();
rows.sort_by_key(|row| row.position);
rows
}
pub(crate) fn open_holds_of(
&self,
handle: AccountHandle,
after: Option<u64>,
) -> impl Iterator<Item = &HoldView<P>> {
let start = after.map_or(Bound::Included((handle, 0)), |position| {
Bound::Excluded((handle, position))
});
self.open_holds
.range((start, Bound::Included((handle, u64::MAX))))
.filter_map(|(_, id)| self.holds.get(id))
}
pub(crate) fn account_row(&self, handle: AccountHandle) -> Option<AccountRow> {
let account = self.account_state(handle)?;
Some(AccountRow {
handle: account.registration.handle,
path: account.registration.path.clone(),
kind: account.registration.kind,
position: account.position,
})
}
pub(crate) fn handle_of(&self, path: &AccountPath) -> Option<AccountHandle> {
self.paths.get(path).copied()
}
pub(crate) fn account_records(&self, handle: AccountHandle) -> &[u64] {
self.account_state(handle)
.map_or(&[], |account| account.records.as_slice())
}
pub(crate) fn seal_positions(&self) -> Vec<(u64, PeriodId)> {
let mut seals: Vec<(u64, PeriodId)> = self
.periods
.values()
.filter_map(|view| Some((view.sealed.as_ref()?.position, view.id.clone())))
.collect();
seals.sort();
seals
}
pub(crate) fn seal_leaves(
&self,
period: &PeriodId,
) -> impl Iterator<Item = (Currency, Layer, AccountHandle, Balance<P>)> {
self.seal_trees
.iter()
.filter(move |((sealed, ..), _)| sealed == period)
.flat_map(|((_, currency, layer), tree)| {
tree.leaves().filter_map(move |(index, balance)| {
Some((*currency, *layer, AccountHandle::new(index)?, balance))
})
})
}
pub(crate) fn seal_leaf(&self, period: &PeriodId, key: BalanceKey) -> Option<Balance<P>> {
self.seal_trees
.get(&(period.clone(), key.currency, key.layer))
.map(|tree| tree.leaf(key.handle))
.filter(|balance| !balance.is_zero())
}
pub(crate) fn seal_tree(
&self,
period: &PeriodId,
currency: Currency,
layer: Layer,
) -> Option<&BalanceTree<P>> {
self.seal_trees.get(&(period.clone(), currency, layer))
}
#[cfg(feature = "testing")]
pub(crate) fn last_posting(&self) -> Option<u64> {
self.by_booking
.iter()
.map(|(.., position, _)| *position)
.max()
}
pub(crate) fn by_booking(
&self,
key: BalanceKey,
) -> impl Iterator<Item = (Date, u64, u16)> + '_ {
self.by_booking
.range((key, Date::MIN, 0, 0)..=(key, Date::MAX, u64::MAX, u16::MAX))
.map(|(_, booking, position, index)| (*booking, *position, *index))
}
pub(crate) fn apply(&mut self, write: WriteSet<P>) -> Result<(), ApplyError> {
let size = self.tree.size();
if write.position != size {
return Err(ApplyError::Stale);
}
let balances = write
.balances
.iter()
.map(|delta| {
self.balance(delta.key)
.checked_add(&delta.delta)
.map(|balance| (delta.key, balance))
})
.collect::<Result<Vec<_>, _>>()
.map_err(|_| ApplyError::Inconsistent)?;
if let Some(HoldEffect::Resolved { hold, .. }) = &write.hold
&& self
.holds
.get(hold)
.is_none_or(|view| view.resolution.is_some())
{
return Err(ApplyError::Inconsistent);
}
let mut applied = Vec::new();
for row in &write.index {
if let IndexRow::Applied { posting, delta } = row {
let total = StateView::applied(self, *posting)
.checked_add(*delta)
.map_err(|_| ApplyError::Inconsistent)?;
applied.push((*posting, total));
}
}
self.tree
.push(leaf_hash(&write.leaf))
.map_err(|_| ApplyError::Inconsistent)?;
let fingerprint = write.index.iter().find_map(|row| match row {
IndexRow::EntryKey { fingerprint, .. } => *fingerprint,
_ => None,
});
let entry = write.index.iter().find_map(|row| match row {
IndexRow::EntryKey { id, .. } => Some(*id),
_ => None,
});
let cleared_keys: Vec<BalanceKey> = write
.index
.iter()
.filter_map(|row| match row {
IndexRow::Applied { posting, .. } => self.posting_key(*posting),
_ => None,
})
.collect();
for (key, balance) in balances {
self.balances.insert(key, balance);
}
for (posting, total) in applied {
self.applied.insert(posting, total);
}
for key in cleared_keys {
self.clearing_refs.insert((key, write.position));
}
for row in write.postings {
if let Some(account) = self.account_mut(row.key.handle) {
account.last_booked = account.last_booked.max(Some(row.booking));
}
if let Some(entry) = entry {
self.by_booking
.insert((row.key, row.booking, write.position, row.index));
self.lines.insert(
(row.key, write.position, row.index),
Line {
entry,
side: row.side,
amount: row.amount,
booking: row.booking,
value: row.value,
dimensions: row.dimensions.clone(),
balance_after: row.balance_after,
},
);
}
self.postings.insert(
(row.booking, write.position, row.index),
Posted {
key: row.key,
side: row.side,
amount: row.amount,
dimensions: row.dimensions,
},
);
}
for row in write.index {
self.index(row, &write.record);
}
if let Some(effect) = write.hold {
self.hold_index(effect);
}
for row in write.outstanding {
self.outstanding.insert((row.handle, row.currency), row);
}
self.leaves.push(StoredLeaf {
bytes: write.leaf,
memo: write.memo,
fingerprint,
});
Ok(())
}
fn hold_index(&mut self, effect: HoldEffect<P>) {
match effect {
HoldEffect::Opened(view) => {
if let Some(expires) = view.expires {
self.due.insert((expires, view.position), view.id);
}
for leg in &view.legs {
self.open_holds.insert((leg.handle, view.position), view.id);
}
self.holds.insert(view.id, view);
}
HoldEffect::Resolved { hold, by, how } => {
if let Some(view) = self.holds.get_mut(&hold) {
view.resolution = Some((by, how));
if let Some(expires) = view.expires {
self.due.remove(&(expires, view.position));
}
for leg in &view.legs {
self.open_holds.remove(&(leg.handle, view.position));
}
}
}
}
}
fn account_mut(&mut self, handle: AccountHandle) -> Option<&mut AccountState> {
self.accounts.get_mut(usize::try_from(handle.get()).ok()?)
}
fn account_state(&self, handle: AccountHandle) -> Option<&AccountState> {
self.accounts.get(usize::try_from(handle.get()).ok()?)
}
fn index(&mut self, row: IndexRow<P>, record: &Record<P>) {
match row {
IndexRow::EntryKey {
key,
id,
position,
fingerprint,
} => {
self.keys.insert(
key,
EntryMeta {
id,
position,
fingerprint,
},
);
if let Record::Entry(entry) = record {
self.records.insert(
id,
EntryView {
position,
record: entry.clone(),
},
);
}
self.entries = self.entries.saturating_add(1);
}
IndexRow::Reversal { target, by } => {
self.reversals.insert(target, by);
}
IndexRow::Account {
registration,
position,
} => self.register(registration, position),
IndexRow::AccountVersion {
handle,
term,
effective,
position,
} => {
if let Some(account) = self.account_mut(handle) {
if account.records.last() != Some(&position) {
account.records.push(position);
}
match term {
AccountTerm::Window(value) => account.windows.push(Versioned {
value,
effective,
position,
}),
AccountTerm::Limit(value) => account.limits.push(Versioned {
value,
effective,
position,
}),
AccountTerm::Class(value) => account.classes.push(Versioned {
value,
effective,
position,
}),
}
}
}
IndexRow::CurrencyVersion {
code,
exponent,
effective,
position,
} => self.currencies.entry(code).or_default().push(Versioned {
value: exponent,
effective,
position,
}),
IndexRow::PolicyVersion {
policy,
effective,
position,
} => self.policies.push(Versioned {
value: policy,
effective,
position,
}),
IndexRow::Period {
id,
start,
end,
position,
} => {
self.period_starts.insert(id.clone(), start);
self.periods.insert(
start,
PeriodView {
id,
start,
end,
position,
sealed: None,
},
);
}
IndexRow::Sealed { period, position } => {
let start = self.period_starts.get(&period).copied();
if let (Some(view), Record::Seal(seal)) =
(start.and_then(|start| self.periods.get_mut(&start)), record)
{
self.watermark = Some(view.end);
view.sealed = Some(SealReceipt {
period,
position,
seal: seal.clone(),
});
}
}
IndexRow::SealTree {
period,
currency,
layer,
leaves,
} => {
if let Record::Seal(seal) = record {
let mut tree = BalanceTree::new(currency, layer, seal.parts().accounts);
for (handle, balance) in leaves {
let _ = tree.set(handle, balance);
}
self.seal_trees.insert((period, currency, layer), tree);
}
}
IndexRow::ClearingKey { key, position } => {
self.clearing_keys.insert(key, position);
if let Record::Clearing(event) = record {
self.clearings.insert(
position,
ClearingView {
position,
event: event.clone(),
reset_by: None,
},
);
}
}
IndexRow::Applied { .. } => {}
IndexRow::ClearingReset { clearing, position } => {
if let Some(view) = self.clearings.get_mut(&clearing) {
view.reset_by = Some(position);
}
}
}
}
fn register(&mut self, registration: AccountRegistration, position: u64) {
let path = registration.path.clone();
let has_descendant = self
.paths
.range((Bound::Excluded(&path), Bound::Unbounded))
.next()
.is_some_and(|(next, _)| path.is_ancestor_of(next));
let mut ancestor = path.parent();
while let Some(parent) = ancestor {
if let Some(handle) = self.paths.get(&parent).copied()
&& let Some(account) = self.account_mut(handle)
{
account.is_leaf = false;
}
ancestor = parent.parent();
}
self.paths.insert(path, registration.handle);
self.accounts.push(AccountState {
registration,
position,
records: vec![position],
windows: Vec::new(),
limits: Vec::new(),
classes: Vec::new(),
is_leaf: !has_descendant,
last_booked: None,
});
}
}
impl<const P: u8> StateView<P> for MemState<P> {
fn header(&self) -> Header<P> {
Header {
ledger: self.ledger.clone(),
size: self.tree.size(),
accounts: u32::try_from(self.accounts.len()).unwrap_or(u32::MAX),
entries: self.entries,
watermark: self.watermark,
last_booking: self
.postings
.last_key_value()
.map(|((booking, ..), _)| *booking),
last_period: self.periods.last_key_value().map(|(_, view)| view.clone()),
}
}
fn entry_by_key(&self, key: &IdempotencyKey) -> Option<EntryMeta> {
self.keys.get(key).copied()
}
fn account_by_path(&self, path: &AccountPath) -> Option<AccountView> {
self.account(*self.paths.get(path)?)
}
fn account(&self, handle: AccountHandle) -> Option<AccountView> {
self.account_state(handle)?.view()
}
fn account_terms(&self, handle: AccountHandle, on: Date) -> Option<Terms> {
let account = self.account_state(handle)?;
Some(Terms {
class: in_force(&account.classes, on)?,
window: in_force(&account.windows, on)?,
limit: in_force(&account.limits, on)?,
})
}
fn currency(&self, code: Currency, on: Date) -> Option<u8> {
in_force(self.currencies.get(&code)?, on)
}
fn currency_latest(&self, code: Currency) -> Option<Versioned<u8>> {
self.currencies.get(&code)?.last().cloned()
}
fn policy(&self, on: Date) -> LedgerPolicy {
in_force(&self.policies, on).unwrap_or_default()
}
fn policy_latest(&self) -> Option<Versioned<LedgerPolicy>> {
self.policies.last().cloned()
}
fn period_at(&self, date: Date) -> Option<PeriodView<P>> {
let (_, view) = self.periods.range(..=date).next_back()?;
(date <= view.end).then(|| view.clone())
}
fn period(&self, id: &PeriodId) -> Option<PeriodView<P>> {
self.periods.get(self.period_starts.get(id)?).cloned()
}
fn first_unsealed(&self) -> Option<PeriodView<P>> {
self.periods
.values()
.find(|view| view.sealed.is_none())
.cloned()
}
fn entry(&self, id: EntryId) -> Option<EntryView<P>> {
self.records.get(&id).cloned()
}
fn reversed_by(&self, id: EntryId) -> Option<EntryId> {
self.reversals.get(&id).copied()
}
fn hold(&self, id: EntryId) -> Option<HoldView<P>> {
self.holds.get(&id).cloned()
}
fn outstanding_hold_debits(&self, handle: AccountHandle, currency: Currency) -> Amount<P> {
self.outstanding
.get(&(handle, currency))
.map_or(Amount::ZERO, |row| row.debits)
}
fn outstanding_hold_credits(&self, handle: AccountHandle, currency: Currency) -> Amount<P> {
self.outstanding
.get(&(handle, currency))
.map_or(Amount::ZERO, |row| row.credits)
}
fn balance(&self, key: BalanceKey) -> Balance<P> {
self.balances.get(&key).copied().unwrap_or(Balance::ZERO)
}
fn clearing_by_key(&self, key: &IdempotencyKey) -> Option<ClearingView<P>> {
self.clearing_at(*self.clearing_keys.get(key)?)
}
fn clearing_at(&self, position: u64) -> Option<ClearingView<P>> {
self.clearings.get(&position).cloned()
}
fn applied(&self, posting: PostingRef) -> Amount<P> {
self.applied.get(&posting).copied().unwrap_or(Amount::ZERO)
}
fn all_balances(&self) -> Vec<(BalanceKey, Balance<P>)> {
self.balances.iter().map(|(key, b)| (*key, *b)).collect()
}
fn postings_after(&self, date: Date) -> Vec<Movement<P>> {
self.postings
.range((
Bound::Excluded((date, u64::MAX, u16::MAX)),
Bound::Unbounded,
))
.map(|(_, posted)| Movement {
key: posted.key,
side: posted.side,
amount: posted.amount,
})
.collect()
}
fn pnl_slices(&self, through: Date) -> Vec<Slice<P>> {
let mut slices: BTreeMap<(AccountHandle, Currency, &Dimensions), Balance<P>> =
BTreeMap::new();
for posted in self
.postings
.range(..=(through, u64::MAX, u16::MAX))
.map(|(_, p)| p)
{
let in_scope = self
.account_state(posted.key.handle)
.is_some_and(|account| {
matches!(
account.registration.kind,
AccountKind::Income | AccountKind::Expense
)
});
if posted.key.layer != Layer::Settled || !in_scope {
continue;
}
let slice = slices
.entry((posted.key.handle, posted.key.currency, &posted.dimensions))
.or_insert(Balance::ZERO);
*slice = slice.post(posted.side, posted.amount).unwrap_or(*slice);
}
slices
.into_iter()
.map(|((handle, currency, dimensions), balance)| Slice {
handle,
currency,
dimensions: dimensions.clone(),
balance,
})
.collect()
}
}