use std::collections::{BTreeSet, HashMap};
use jiff::Timestamp;
use tollgate_core::{AccountId, CostUnits, FencingToken, LeaseId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Settled {
Released,
Expired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LeaseState {
Active,
Settled(Settled),
}
#[derive(Debug)]
pub(crate) struct LeaseRecord {
pub(crate) account_id: AccountId,
pub(crate) fencing_token: FencingToken,
pub(crate) granted: CostUnits,
pub(crate) used: CostUnits,
pub(crate) expires_at: Timestamp,
credited: CostUnits,
pub(crate) funding: crate::memory::Drawn,
pub(crate) period_start: Timestamp,
state: LeaseState,
}
impl LeaseRecord {
pub(crate) fn opened(
account_id: AccountId,
fencing_token: FencingToken,
granted: CostUnits,
expires_at: Timestamp,
) -> Self {
LeaseRecord {
account_id,
fencing_token,
granted,
used: CostUnits::ZERO,
expires_at,
credited: CostUnits::ZERO,
funding: crate::memory::Drawn::default(),
period_start: Timestamp::UNIX_EPOCH,
state: LeaseState::Active,
}
}
pub(crate) fn funded_by(
mut self,
funding: crate::memory::Drawn,
period_start: Timestamp,
) -> Self {
self.funding = funding;
self.period_start = period_start;
self
}
pub(crate) fn is_active(&self) -> bool {
self.state == LeaseState::Active
}
pub(crate) fn credited(&self) -> CostUnits {
self.credited
}
}
#[derive(Default)]
pub(crate) struct Leases {
records: HashMap<LeaseId, LeaseRecord>,
active_by_expiry: BTreeSet<(Timestamp, LeaseId)>,
#[cfg(test)]
examined: std::cell::Cell<usize>,
}
impl Leases {
pub(crate) fn open(&mut self, lease_id: LeaseId, record: LeaseRecord) {
self.active_by_expiry.insert((record.expires_at, lease_id));
self.records.insert(lease_id, record);
}
pub(crate) fn get(&self, lease_id: LeaseId) -> Option<&LeaseRecord> {
self.records.get(&lease_id)
}
pub(crate) fn get_mut(&mut self, lease_id: LeaseId) -> Option<&mut LeaseRecord> {
self.records.get_mut(&lease_id)
}
pub(crate) fn settle(
&mut self,
lease_id: LeaseId,
settled: Settled,
credited: CostUnits,
) -> bool {
let Some(record) = self.records.get_mut(&lease_id) else {
return false;
};
if record.state != LeaseState::Active {
return false;
}
self.active_by_expiry.remove(&(record.expires_at, lease_id));
record.state = LeaseState::Settled(settled);
record.credited = credited;
true
}
pub(crate) fn reclaimable(&self, cutoff: Option<Timestamp>, limit: usize) -> Vec<LeaseId> {
self.active_by_expiry
.iter()
.inspect(|_| self.mark_examined())
.take_while(|(expires_at, _)| cutoff.is_some_and(|cutoff| *expires_at <= cutoff))
.take(limit)
.map(|(_, lease_id)| *lease_id)
.collect()
}
pub(crate) fn active_of(&self, account: AccountId) -> impl Iterator<Item = &LeaseRecord> {
self.active_by_expiry.iter().filter_map(move |(_, id)| {
self.mark_examined();
let record = self.records.get(id)?;
(record.account_id == account).then_some(record)
})
}
pub(crate) fn len(&self) -> usize {
self.records.len()
}
pub(crate) fn active_len(&self) -> usize {
self.active_by_expiry.len()
}
#[cfg(test)]
fn mark_examined(&self) {
self.examined.set(self.examined.get() + 1);
}
#[cfg(not(test))]
#[expect(
clippy::unused_self,
reason = "the counter this stands in for exists only under cfg(test)"
)]
fn mark_examined(&self) {}
#[cfg(test)]
pub(crate) fn examined(&self) -> usize {
self.examined.get()
}
}
#[cfg(test)]
mod tests {
use super::*;
const ACCOUNT: AccountId = AccountId(1);
fn t(secs: i64) -> Timestamp {
Timestamp::from_second(secs).unwrap()
}
fn open_at(leases: &mut Leases, id: u128, expires_at: i64) -> LeaseId {
let lease_id = LeaseId(id);
leases.open(
lease_id,
LeaseRecord::opened(ACCOUNT, FencingToken(1), CostUnits(100), t(expires_at)),
);
lease_id
}
#[test]
fn settling_removes_a_lease_from_the_index_but_keeps_its_record() {
let mut leases = Leases::default();
let lease_id = open_at(&mut leases, 1, 60);
assert_eq!(leases.active_len(), 1);
assert_eq!(leases.reclaimable(Some(t(60)), 10), vec![lease_id]);
assert!(leases.settle(lease_id, Settled::Released, CostUnits(40)));
assert_eq!(leases.active_len(), 0);
assert!(
leases.reclaimable(Some(t(60)), 10).is_empty(),
"a released lease must never be reclaimed as well"
);
assert_eq!(
leases.active_of(ACCOUNT).count(),
0,
"and must stop counting towards active grants"
);
let record = leases.get(lease_id).expect("record retained");
assert!(!record.is_active());
assert_eq!(record.credited(), CostUnits(40));
assert_eq!(leases.len(), 1);
}
#[test]
fn a_lease_settles_once() {
let mut leases = Leases::default();
let lease_id = open_at(&mut leases, 1, 60);
assert!(leases.settle(lease_id, Settled::Released, CostUnits(40)));
assert!(!leases.settle(lease_id, Settled::Expired, CostUnits(100)));
assert_eq!(
leases.get(lease_id).unwrap().credited(),
CostUnits(40),
"the second settlement must not overwrite the first's credit"
);
assert!(!leases.settle(LeaseId(99), Settled::Released, CostUnits(0)));
}
#[test]
fn reclaimable_yields_due_leases_oldest_first_and_stops_there() {
let mut leases = Leases::default();
let early = open_at(&mut leases, 1, 10);
let middle = open_at(&mut leases, 2, 20);
let late = open_at(&mut leases, 3, 500);
assert_eq!(
leases.reclaimable(Some(t(25)), 10),
vec![early, middle],
"due at expiry + grace, and `late` is not"
);
assert_eq!(
leases.reclaimable(Some(t(25)), 1),
vec![early],
"the limit truncates from the oldest end"
);
assert!(leases.reclaimable(Some(t(9)), 10).is_empty());
let before = leases.examined();
assert!(leases.reclaimable(None, 10).is_empty());
assert_eq!(
leases.examined() - before,
1,
"nothing is due, so the walk must stop after the first lease \
rather than filtering the whole index"
);
assert_eq!(leases.active_len(), 3, "listing settles nothing");
let _ = late;
}
#[test]
fn active_of_selects_by_account() {
const OTHER: AccountId = AccountId(2);
let mut leases = Leases::default();
open_at(&mut leases, 1, 60);
leases.open(
LeaseId(2),
LeaseRecord::opened(OTHER, FencingToken(1), CostUnits(7), t(60)),
);
assert_eq!(leases.active_of(ACCOUNT).count(), 1);
let other: Vec<_> = leases.active_of(OTHER).map(|lease| lease.granted).collect();
assert_eq!(other, vec![CostUnits(7)]);
}
}