use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
#[derive(Debug, Clone)]
pub struct SharedTokenBudget {
used: Arc<AtomicU64>,
cap: u64,
}
impl SharedTokenBudget {
pub fn new(cap: u64) -> Self {
Self {
used: Arc::new(AtomicU64::new(0)),
cap,
}
}
pub fn used(&self) -> u64 {
self.used.load(Ordering::Relaxed)
}
pub fn cap(&self) -> u64 {
self.cap
}
fn try_add(&self, tokens: u64) -> Result<(), u64> {
let mut current = self.used.load(Ordering::Relaxed);
loop {
let next = current.saturating_add(tokens);
if next > self.cap {
return Err(next);
}
match self.used.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return Ok(()),
Err(observed) => current = observed,
}
}
}
fn add_unchecked(&self, tokens: u64) {
let mut current = self.used.load(Ordering::Relaxed);
loop {
let next = current.saturating_add(tokens);
match self.used.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(observed) => current = observed,
}
}
}
fn sub(&self, tokens: u64) {
let mut current = self.used.load(Ordering::Relaxed);
loop {
let next = current.saturating_sub(tokens);
match self.used.compare_exchange_weak(
current,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return,
Err(observed) => current = observed,
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LedgerLimits {
pub per_execution_tokens: u64,
pub max_held_tokens: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LedgerRefusal {
HeldReserve { held: u64, limit: u64 },
PerExecution { requested: u64, limit: u64 },
AllExecutions { requested: u64, limit: u64 },
}
#[derive(Debug, Default)]
pub(super) struct TokenState {
used: u64,
held: u64,
}
#[derive(Debug)]
pub struct TokenLedger {
limits: LedgerLimits,
state: Mutex<TokenState>,
aggregate: SharedTokenBudget,
}
impl TokenLedger {
pub fn new(limits: LedgerLimits, aggregate: SharedTokenBudget) -> Self {
Self {
limits,
state: Mutex::new(TokenState::default()),
aggregate,
}
}
pub fn used(&self) -> u64 {
self.locked().used
}
pub fn held(&self) -> u64 {
self.locked().held
}
fn locked(&self) -> MutexGuard<'_, TokenState> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
#[cfg(test)]
pub(super) fn lock_state(&self) -> std::sync::LockResult<MutexGuard<'_, TokenState>> {
self.state.lock()
}
#[cfg(test)]
pub(super) fn state_is_poisoned(&self) -> bool {
self.state.is_poisoned()
}
pub fn reserve(&self, tokens: u64) -> Result<(), LedgerRefusal> {
let mut state = self.locked();
if state.held > self.limits.max_held_tokens {
return Err(LedgerRefusal::HeldReserve {
held: state.held,
limit: self.limits.max_held_tokens,
});
}
let next_used = state.used.saturating_add(tokens);
if next_used > self.limits.per_execution_tokens {
return Err(LedgerRefusal::PerExecution {
requested: next_used,
limit: self.limits.per_execution_tokens,
});
}
if let Err(requested) = self.aggregate.try_add(tokens) {
return Err(LedgerRefusal::AllExecutions {
requested,
limit: self.aggregate.cap(),
});
}
state.used = next_used;
Ok(())
}
pub fn reconcile(&self, reserved: u64, reported: u64, indeterminate: u64) {
let mut state = self.locked();
let keep = reported.saturating_add(indeterminate);
let release = reserved.saturating_sub(keep);
state.used = state.used.saturating_sub(release);
state.held = state.held.saturating_add(indeterminate);
if release > 0 {
self.aggregate.sub(release);
}
}
}
impl TokenLedger {
pub fn settle_held(&self, held_estimate: u64, reported: u64, indeterminate: u64) {
let mut state = self.locked();
state.held = state.held.saturating_sub(held_estimate);
let keep = reported.saturating_add(indeterminate);
let release = held_estimate.saturating_sub(keep);
let excess = keep.saturating_sub(held_estimate);
state.used = state.used.saturating_sub(release).saturating_add(excess);
state.held = state.held.saturating_add(indeterminate);
if release > 0 {
self.aggregate.sub(release);
}
if excess > 0 {
self.aggregate.add_unchecked(excess);
}
}
}
impl Drop for TokenLedger {
fn drop(&mut self) {
let state = self.state.get_mut().unwrap_or_else(PoisonError::into_inner);
let returnable = state.used.saturating_sub(state.held);
if returnable > 0 {
self.aggregate.sub(returnable);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn limits(per_execution_tokens: u64, max_held_tokens: u64) -> LedgerLimits {
LedgerLimits {
per_execution_tokens,
max_held_tokens,
}
}
#[test]
fn aggregate_cap_binds_across_ledgers_and_refunds_on_drop() {
let aggregate = SharedTokenBudget::new(100);
let first = TokenLedger::new(limits(u64::MAX, 0), aggregate.clone());
let second = TokenLedger::new(limits(u64::MAX, 0), aggregate.clone());
first.reserve(60).expect("first fits");
assert_eq!(
second.reserve(50),
Err(LedgerRefusal::AllExecutions {
requested: 110,
limit: 100
})
);
assert_eq!(aggregate.used(), 60, "a refusal charges nothing");
second.reserve(40).expect("second fills the remainder");
assert_eq!(aggregate.used(), 100);
drop(first);
assert_eq!(aggregate.used(), 40, "drop refunds the first ledger's 60");
second.reserve(60).expect("the refund is reusable");
}
#[test]
fn held_reserve_stays_charged_to_the_aggregate_after_drop() {
let aggregate = SharedTokenBudget::new(1_000);
{
let ledger = TokenLedger::new(limits(1_000, 1_000), aggregate.clone());
ledger.reserve(300).expect("fits");
ledger.reconcile(300, 100, 50);
assert_eq!(ledger.used(), 150);
assert_eq!(ledger.held(), 50);
assert_eq!(aggregate.used(), 150);
}
assert_eq!(aggregate.used(), 50);
}
#[test]
fn held_reserve_past_the_ceiling_refuses_further_reservation() {
let ledger = TokenLedger::new(limits(1_000, 10), SharedTokenBudget::new(1_000));
ledger.reserve(100).expect("fits");
ledger.reconcile(100, 0, 100);
assert_eq!(
ledger.reserve(1),
Err(LedgerRefusal::HeldReserve {
held: 100,
limit: 10
})
);
}
#[test]
fn settle_held_charges_reported_usage_above_the_estimate() {
let aggregate = SharedTokenBudget::new(5);
let ledger = TokenLedger::new(limits(5, 100), aggregate.clone());
ledger.reserve(1).expect("fits");
ledger.reconcile(1, 0, 1);
ledger.settle_held(1, 7, 0);
assert_eq!((ledger.used(), ledger.held(), aggregate.used()), (7, 0, 7));
assert!(
ledger.reserve(1).is_err(),
"spend past the caps refuses the next reservation"
);
}
#[test]
fn a_reservation_beyond_i64_max_is_refused_not_wrapped() {
let aggregate = SharedTokenBudget::new(10);
let ledger = TokenLedger::new(limits(u64::MAX, 0), aggregate.clone());
assert!(ledger.reserve(u64::MAX).is_err());
assert_eq!(aggregate.used(), 0);
}
}