use std::num::NonZeroUsize;
use async_trait::async_trait;
use jiff::{SignedDuration, Timestamp};
use tokio::sync::broadcast;
use tollgate_core::{
AccountId, AccountStatus, BudgetSchedule, CapacityClass, CostUnits, FencingToken, Generation,
KeyId, LeaseGrant, LeaseId, Principal, PublishableSnapshot, UsageEvent,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreError(pub String);
impl std::fmt::Display for StoreError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "store error: {}", self.0)
}
}
impl std::error::Error for StoreError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AllocateError {
UnknownAccount,
AccountInactive,
InsufficientBalance,
BalanceExhausted(tollgate_core::BalanceExhaustion),
BalanceInsufficient(tollgate_core::BalanceShortfall),
InvalidTtl,
UnknownLease,
Fenced,
LeaseNotActive,
InvalidRelease,
BalanceOverflow,
Storage(StoreError),
}
impl AllocateError {
pub const NAMES: [&'static str; Self::COUNT] = [
"unknown_account",
"account_inactive",
"insufficient_balance",
"invalid_ttl",
"unknown_lease",
"fenced",
"lease_not_active",
"invalid_release",
"storage",
"balance_exhausted",
"balance_insufficient",
"balance_overflow",
];
pub const COUNT: usize = 12;
#[must_use]
pub const fn index(&self) -> usize {
match self {
AllocateError::UnknownAccount => 0,
AllocateError::AccountInactive => 1,
AllocateError::InsufficientBalance => 2,
AllocateError::InvalidTtl => 3,
AllocateError::UnknownLease => 4,
AllocateError::Fenced => 5,
AllocateError::LeaseNotActive => 6,
AllocateError::InvalidRelease => 7,
AllocateError::Storage(_) => 8,
AllocateError::BalanceExhausted(_) => 9,
AllocateError::BalanceInsufficient(_) => 10,
AllocateError::BalanceOverflow => 11,
}
}
#[must_use]
pub const fn name(&self) -> &'static str {
Self::NAMES[self.index()]
}
}
impl std::fmt::Display for AllocateError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AllocateError::UnknownAccount => f.write_str("unknown account"),
AllocateError::AccountInactive => f.write_str("account inactive"),
AllocateError::InsufficientBalance => f.write_str("insufficient balance"),
AllocateError::BalanceExhausted(_) => f.write_str("account balance exhausted"),
AllocateError::BalanceInsufficient(evidence) => write!(
f,
"insufficient balance ({} units remain, all held in leases)",
evidence.remaining
),
AllocateError::InvalidTtl => {
f.write_str("lease TTL must specify one positive duration")
}
AllocateError::UnknownLease => f.write_str("unknown lease"),
AllocateError::Fenced => f.write_str("fencing token mismatch"),
AllocateError::LeaseNotActive => f.write_str("lease not active"),
AllocateError::InvalidRelease => f.write_str("invalid release"),
AllocateError::BalanceOverflow => {
f.write_str("deposit exceeds account funding capacity")
}
AllocateError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for AllocateError {}
impl From<StoreError> for AllocateError {
fn from(error: StoreError) -> Self {
AllocateError::Storage(error)
}
}
#[derive(Debug, Clone, Copy)]
pub struct AccountConfig {
pub account_id: AccountId,
pub initial_balance: CostUnits,
pub status: AccountStatus,
pub capacity_class: CapacityClass,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Conservation {
pub deposited: CostUnits,
pub overage_recorded: CostUnits,
pub balance: CostUnits,
pub active_lease_grants: CostUnits,
pub settled_usage: CostUnits,
pub settlement_loss: CostUnits,
pub expired: CostUnits,
}
impl Conservation {
#[must_use]
pub fn holds(&self) -> bool {
let Some(funded) = self.deposited.checked_add(self.overage_recorded) else {
return false;
};
let mut sum = self.balance;
for part in [
self.active_lease_grants,
self.settled_usage,
self.settlement_loss,
self.expired,
] {
match sum.checked_add(part) {
Some(next) => sum = next,
None => return false,
}
}
sum == funded
}
}
#[derive(Debug, Clone, Copy)]
pub struct GrantPolicy {
pub shrink_divisor: u64,
pub min_grant: CostUnits,
pub max_ttl: SignedDuration,
pub reclaim_grace: SignedDuration,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GrantPolicyError(pub &'static str);
impl std::fmt::Display for GrantPolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for GrantPolicyError {}
impl Default for GrantPolicy {
fn default() -> Self {
GrantPolicy {
shrink_divisor: 2,
min_grant: CostUnits(1),
max_ttl: SignedDuration::from_secs(300),
reclaim_grace: SignedDuration::from_secs(30),
}
}
}
impl GrantPolicy {
pub fn reclaim_cutoff(&self, now: Timestamp) -> Option<Timestamp> {
now.checked_sub(self.reclaim_grace).ok()
}
pub fn validate(&self) -> Result<(), GrantPolicyError> {
if self.shrink_divisor == 0 {
return Err(GrantPolicyError("shrink_divisor must be positive"));
}
if self.min_grant.is_zero() {
return Err(GrantPolicyError("min_grant must be positive"));
}
if self.max_ttl <= SignedDuration::ZERO {
return Err(GrantPolicyError("max_ttl must be positive"));
}
if self.reclaim_grace < SignedDuration::ZERO {
return Err(GrantPolicyError("reclaim_grace must not be negative"));
}
Ok(())
}
#[must_use]
pub fn grant(&self, requested: CostUnits, balance: CostUnits) -> Option<CostUnits> {
if requested.is_zero()
|| balance.is_zero()
|| self.shrink_divisor == 0
|| self.min_grant.is_zero()
{
return None;
}
let cap = (balance.get() / self.shrink_divisor).max(self.min_grant.get());
Some(CostUnits(
requested.get().min(cap).min(balance.get()).max(1),
))
}
#[must_use]
pub fn consolidation_grant(
&self,
requested: CostUnits,
balance: CostUnits,
floor: CostUnits,
needed: CostUnits,
) -> Option<CostUnits> {
let sized = self.grant(requested, balance)?.max(floor.min(balance));
Some(if needed <= balance {
sized.max(needed)
} else {
sized
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "wire", derive(serde::Serialize, serde::Deserialize))]
pub struct ReclaimedLease {
pub lease_id: LeaseId,
pub account_id: AccountId,
pub forfeited: CostUnits,
}
pub const DEFAULT_RECLAIM_BATCH_LIMIT: NonZeroUsize =
NonZeroUsize::new(256).expect("the reclaim batch limit is nonzero");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReclaimBatch {
reclaimed: Vec<ReclaimedLease>,
saturated: bool,
}
impl ReclaimBatch {
pub fn try_new(
reclaimed: Vec<ReclaimedLease>,
limit: NonZeroUsize,
) -> Result<Self, StoreError> {
if reclaimed.len() > limit.get() {
return Err(StoreError(format!(
"reclaim backend returned {} leases for a batch limit of {}",
reclaimed.len(),
limit
)));
}
Ok(ReclaimBatch {
saturated: reclaimed.len() == limit.get(),
reclaimed,
})
}
#[must_use]
pub fn reclaimed(&self) -> &[ReclaimedLease] {
&self.reclaimed
}
#[must_use]
pub fn len(&self) -> usize {
self.reclaimed.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.reclaimed.is_empty()
}
#[must_use]
pub fn is_saturated(&self) -> bool {
self.saturated
}
#[must_use]
pub fn into_reclaimed(self) -> Vec<ReclaimedLease> {
self.reclaimed
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "wire", derive(serde::Serialize, serde::Deserialize))]
pub struct Allocation {
#[cfg_attr(feature = "wire", serde(flatten))]
pub grant: LeaseGrant,
#[cfg_attr(
feature = "wire",
serde(default, skip_serializing_if = "Option::is_none")
)]
pub funding: Option<tollgate_core::BalanceShortfall>,
}
#[async_trait]
pub trait LeaseAllocator: Send + Sync {
async fn acquire(
&self,
account: AccountId,
requested: CostUnits,
ttl: SignedDuration,
now: Timestamp,
) -> Result<Allocation, AllocateError>;
async fn release(
&self,
lease_id: LeaseId,
fencing_token: FencingToken,
unspent: CostUnits,
now: Timestamp,
) -> Result<(), AllocateError>;
#[allow(
clippy::too_many_arguments,
reason = "one transactional exchange: the release half's capability and credit, the \
grant half's size and demand, and the shared lifetime and clock"
)]
async fn consolidate(
&self,
lease_id: LeaseId,
fencing_token: FencingToken,
unspent: CostUnits,
requested: CostUnits,
needed: CostUnits,
ttl: SignedDuration,
now: Timestamp,
) -> Result<Allocation, AllocateError>;
async fn reclaim_expired_batch(
&self,
now: Timestamp,
limit: NonZeroUsize,
) -> Result<ReclaimBatch, StoreError>;
async fn reclaim_expired(&self, now: Timestamp) -> Result<Vec<ReclaimedLease>, StoreError> {
drain_reclaim_expired(self, now).await
}
}
#[doc(hidden)]
pub async fn drain_reclaim_expired<A>(
allocator: &A,
now: Timestamp,
) -> Result<Vec<ReclaimedLease>, StoreError>
where
A: LeaseAllocator + ?Sized,
{
let mut reclaimed: Vec<ReclaimedLease> = Vec::new();
loop {
let batch = match allocator
.reclaim_expired_batch(now, DEFAULT_RECLAIM_BATCH_LIMIT)
.await
{
Ok(batch) => batch,
Err(error) if reclaimed.is_empty() => return Err(error),
Err(error) => {
let units: u128 = reclaimed
.iter()
.map(|lease| u128::from(lease.forfeited.get()))
.sum();
return Err(StoreError(format!(
"reclaim drain failed after {} leases forfeiting {units} units were committed: {error}",
reclaimed.len()
)));
}
};
let saturated = batch.is_saturated();
reclaimed.extend(batch.into_reclaimed());
if !saturated {
return Ok(reclaimed);
}
tokio::task::yield_now().await;
}
}
#[derive(Debug, Clone)]
pub enum SnapshotResolution {
Present(PublishableSnapshot),
Revoked {
generation: Generation,
},
Unknown,
}
pub const PUSH_CHANNEL_CAPACITY: usize = 256;
#[must_use]
pub fn pushes_exceed_capacity(principals: usize) -> bool {
principals > PUSH_CHANNEL_CAPACITY
}
#[derive(Debug, Clone)]
pub struct SnapshotPush {
pub principal: Principal,
pub resolution: SnapshotResolution,
}
#[async_trait]
pub trait SnapshotSource: Send + Sync {
async fn snapshot(&self, principal: Principal) -> Result<SnapshotResolution, StoreError>;
fn subscribe(&self) -> broadcast::Receiver<SnapshotPush>;
async fn principals(&self) -> Result<Option<Vec<Principal>>, StoreError> {
Ok(None)
}
}
#[async_trait]
pub trait StoreHealth: Send + Sync {
async fn ping(&self) -> Result<(), StoreError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CreateAccountError {
AlreadyExists,
Storage(StoreError),
}
impl std::fmt::Display for CreateAccountError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CreateAccountError::AlreadyExists => f.write_str("account already exists"),
CreateAccountError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for CreateAccountError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct StatusChange {
pub republished: usize,
pub unreadable: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RolledAccount {
pub account_id: AccountId,
pub deposited: CostUnits,
pub expired: CostUnits,
}
pub const DEFAULT_ROLLOVER_BATCH_LIMIT: NonZeroUsize =
NonZeroUsize::new(256).expect("the rollover batch limit is nonzero");
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RolloverBatch {
rolled: Vec<RolledAccount>,
saturated: bool,
}
impl RolloverBatch {
pub fn try_new(rolled: Vec<RolledAccount>, limit: NonZeroUsize) -> Result<Self, StoreError> {
if rolled.len() > limit.get() {
return Err(StoreError(format!(
"rollover backend returned {} accounts for a batch limit of {}",
rolled.len(),
limit
)));
}
Ok(RolloverBatch {
saturated: rolled.len() == limit.get(),
rolled,
})
}
#[must_use]
pub fn rolled(&self) -> &[RolledAccount] {
&self.rolled
}
#[must_use]
pub fn len(&self) -> usize {
self.rolled.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.rolled.is_empty()
}
#[must_use]
pub fn is_saturated(&self) -> bool {
self.saturated
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BudgetError {
UnknownAccount,
Storage(StoreError),
}
impl std::fmt::Display for BudgetError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BudgetError::UnknownAccount => f.write_str("no such account"),
BudgetError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for BudgetError {}
impl From<StoreError> for BudgetError {
fn from(error: StoreError) -> Self {
BudgetError::Storage(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SetStatusError {
UnknownAccount,
AccountClosed,
NotProvisioned,
OperatorHold,
Storage(StoreError),
}
impl std::fmt::Display for SetStatusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SetStatusError::UnknownAccount => f.write_str("unknown account"),
SetStatusError::AccountClosed => f.write_str("account is closed"),
SetStatusError::NotProvisioned => {
f.write_str("account was not created by a provisioner")
}
SetStatusError::OperatorHold => f.write_str("an operator set this account's status"),
SetStatusError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for SetStatusError {}
impl From<StoreError> for SetStatusError {
fn from(error: StoreError) -> Self {
SetStatusError::Storage(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublishSnapshotError {
CredentialMismatch {
key_id: KeyId,
},
StatusMismatch {
ledger: AccountStatus,
submitted: AccountStatus,
},
CapacityClassMismatch {
ledger: CapacityClass,
submitted: CapacityClass,
},
Storage(StoreError),
}
impl std::fmt::Display for PublishSnapshotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PublishSnapshotError::CredentialMismatch { key_id } => {
write!(
f,
"credential {key_id} does not bind the published principal and account"
)
}
PublishSnapshotError::StatusMismatch { ledger, submitted } => write!(
f,
"snapshot status {} contradicts account status {}",
submitted.as_str(),
ledger.as_str()
),
PublishSnapshotError::CapacityClassMismatch { ledger, submitted } => write!(
f,
"snapshot capacity class {} contradicts account capacity class {}",
submitted.as_str(),
ledger.as_str()
),
PublishSnapshotError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for PublishSnapshotError {}
impl From<StoreError> for PublishSnapshotError {
fn from(error: StoreError) -> Self {
PublishSnapshotError::Storage(error)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AccountView {
pub account_id: AccountId,
pub status: AccountStatus,
pub capacity_class: CapacityClass,
pub origin: crate::AdminAuthority,
pub status_set_by: crate::AdminAuthority,
pub schedule: Option<BudgetSchedule>,
pub period_start: Timestamp,
pub conservation: Conservation,
}
#[async_trait]
pub trait AdminStore: Send + Sync {
async fn create_account(
&self,
config: AccountConfig,
) -> Result<crate::AdminReceipt<()>, CreateAccountError>;
async fn create_provisioned_account(
&self,
account: AccountId,
) -> Result<crate::AdminReceipt<()>, CreateAccountError>;
async fn deposit(
&self,
account: AccountId,
units: CostUnits,
) -> Result<crate::AdminReceipt<()>, AllocateError>;
async fn set_account_status(
&self,
account: AccountId,
status: AccountStatus,
) -> Result<crate::AdminReceipt<StatusChange>, SetStatusError>;
async fn activate_provisioned(
&self,
account: AccountId,
) -> Result<crate::AdminReceipt<StatusChange>, SetStatusError>;
async fn set_capacity_class(
&self,
account: AccountId,
class: CapacityClass,
) -> Result<crate::AdminReceipt<StatusChange>, SetStatusError>;
async fn set_budget_schedule(
&self,
account: AccountId,
schedule: Option<BudgetSchedule>,
) -> Result<crate::AdminReceipt<()>, BudgetError>;
async fn roll_due_periods(
&self,
now: Timestamp,
limit: NonZeroUsize,
) -> Result<RolloverBatch, StoreError>;
async fn publish_snapshot(
&self,
principal: Principal,
snapshot: PublishableSnapshot,
) -> Result<crate::AdminReceipt<()>, PublishSnapshotError>;
async fn remove_snapshot(
&self,
principal: Principal,
) -> Result<crate::AdminReceipt<()>, StoreError>;
async fn account_view(&self, account: AccountId) -> Result<Option<AccountView>, StoreError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "wire", derive(serde::Serialize, serde::Deserialize))]
pub struct IngestReport {
pub accepted: u64,
pub duplicate: u64,
pub rejected: u64,
#[cfg_attr(feature = "wire", serde(default))]
pub unattributed: Option<u64>,
}
impl IngestReport {
pub fn validate(&self, submitted: usize) -> Result<(), StoreError> {
let total = self
.accepted
.checked_add(self.duplicate)
.and_then(|n| n.checked_add(self.rejected));
if !total.is_some_and(|n| u64::try_from(submitted) == Ok(n))
|| self.unattributed.is_some_and(|n| n > self.accepted)
{
return Err(StoreError(
"invalid usage acknowledgement cardinality".into(),
));
}
Ok(())
}
}
#[async_trait]
pub trait UsageSink: Send + Sync {
async fn ingest(
&self,
events: &[UsageEvent],
now: Timestamp,
) -> Result<IngestReport, IngestError>;
}
#[derive(Clone, PartialEq, Eq)]
pub struct KeyRecord {
pub key_id: KeyId,
pub account_id: AccountId,
pub principal: Principal,
pub digest: [u8; 32],
pub not_after: Option<Timestamp>,
}
impl std::fmt::Debug for KeyRecord {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeyRecord")
.field("key_id", &self.key_id)
.field("account_id", &self.account_id)
.field("principal", &self.principal)
.field("not_after", &self.not_after)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CredentialActivity {
pub key_id: KeyId,
pub state: CredentialActivityState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CredentialActivityState {
Unknown,
Unobserved,
Committed {
last_committed_at: Timestamp,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Revocation {
Retired,
AlreadyRetired,
}
pub const MAX_INGEST_BATCH: usize = 4_096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IngestError {
Unavailable(StoreError),
Refused(StoreError),
}
impl IngestError {
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(self, IngestError::Unavailable(_))
}
}
impl From<StoreError> for IngestError {
fn from(error: StoreError) -> Self {
IngestError::Unavailable(error)
}
}
impl std::fmt::Display for IngestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IngestError::Unavailable(e) => write!(f, "{e}"),
IngestError::Refused(e) => write!(f, "refused: {e}"),
}
}
}
impl std::error::Error for IngestError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyError {
UnknownKey,
UnknownAccount,
AlreadyExists,
ActiveKeyLimit {
limit: NonZeroUsize,
},
Storage(StoreError),
}
impl std::fmt::Display for KeyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeyError::UnknownKey => f.write_str("no such credential"),
KeyError::UnknownAccount => f.write_str("no such account"),
KeyError::AlreadyExists => f.write_str("credential already exists"),
KeyError::ActiveKeyLimit { limit } => {
write!(f, "account already holds {limit} live credentials")
}
KeyError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for KeyError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeySnapshotError {
UnknownCredential,
Retired {
key_id: KeyId,
},
Publish(PublishSnapshotError),
Storage(StoreError),
}
impl std::fmt::Display for KeySnapshotError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KeySnapshotError::UnknownCredential => f.write_str("no such credential"),
KeySnapshotError::Retired { key_id } => {
write!(f, "credential {key_id} is revoked")
}
KeySnapshotError::Publish(e) => write!(f, "{e}"),
KeySnapshotError::Storage(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for KeySnapshotError {}
impl From<PublishSnapshotError> for KeySnapshotError {
fn from(error: PublishSnapshotError) -> Self {
Self::Publish(error)
}
}
impl From<StoreError> for KeySnapshotError {
fn from(error: StoreError) -> Self {
Self::Storage(error)
}
}
impl From<StoreError> for KeyError {
fn from(error: StoreError) -> Self {
Self::Storage(error)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeySummary {
pub key_id: KeyId,
pub not_after: Option<Timestamp>,
pub revoked_at: Option<Timestamp>,
}
impl KeySummary {
#[must_use]
pub fn is_live(&self, now: Timestamp) -> bool {
self.revoked_at.is_none() && self.not_after.is_none_or(|until| now < until)
}
}
#[async_trait]
pub trait KeyDirectory: crate::KeySource {
async fn credential_activity(
&self,
keys: &[KeyId],
) -> Result<Vec<CredentialActivity>, StoreError>;
async fn insert_key(&self, record: KeyRecord) -> Result<(), KeyError>;
async fn revoke_key(&self, key_id: KeyId, now: Timestamp) -> Result<Revocation, KeyError>;
async fn active_keys(&self, now: Timestamp) -> Result<Vec<KeyRecord>, StoreError>;
async fn account_keys(
&self,
account: AccountId,
after: Option<KeyId>,
limit: NonZeroUsize,
) -> Result<Vec<KeySummary>, KeyError>;
async fn insert_key_within(
&self,
record: KeyRecord,
max_active: NonZeroUsize,
now: Timestamp,
) -> Result<(), KeyError>;
async fn insert_key_within_audited(
&self,
record: KeyRecord,
max_active: NonZeroUsize,
now: Timestamp,
) -> Result<crate::AdminReceipt<()>, KeyError>;
async fn revoke_key_audited(
&self,
key_id: KeyId,
now: Timestamp,
) -> Result<crate::AdminReceipt<Revocation>, KeyError>;
async fn publish_key_snapshot(
&self,
account: AccountId,
key: KeyId,
snapshot: PublishableSnapshot,
) -> Result<crate::AdminReceipt<()>, KeySnapshotError>;
async fn publish_key_snapshot_next(
&self,
account: AccountId,
key: KeyId,
snapshot: PublishableSnapshot,
) -> Result<crate::AdminReceipt<()>, KeySnapshotError>;
async fn remove_key_snapshot(
&self,
account: AccountId,
key: KeyId,
) -> Result<crate::AdminReceipt<()>, KeySnapshotError>;
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::KeySummary;
use jiff::Timestamp;
use tollgate_core::KeyId;
fn at(seconds: i64) -> Timestamp {
Timestamp::from_second(seconds).expect("a test instant")
}
#[test]
fn a_credential_is_dead_at_its_expiry_instant_not_after_it() {
let expiring = |not_after| KeySummary {
key_id: KeyId(1),
not_after: Some(not_after),
revoked_at: None,
};
assert!(
expiring(at(100)).is_live(at(99)),
"live up to the instant before"
);
assert!(
!expiring(at(100)).is_live(at(100)),
"dead *at* the boundary: expiry is exclusive, as both backends filter it"
);
assert!(!expiring(at(100)).is_live(at(101)), "and dead after it");
}
use super::*;
#[derive(Clone, Copy)]
enum ReclaimScript {
FailFirst,
FullBatchThenFail,
}
struct ScriptedReclaimer {
script: ReclaimScript,
calls: AtomicUsize,
}
#[async_trait]
impl LeaseAllocator for ScriptedReclaimer {
async fn acquire(
&self,
_account: AccountId,
_requested: CostUnits,
_ttl: SignedDuration,
_now: Timestamp,
) -> Result<Allocation, AllocateError> {
unreachable!("the full-drain tests only reclaim")
}
async fn release(
&self,
_lease_id: LeaseId,
_fencing_token: FencingToken,
_unspent: CostUnits,
_now: Timestamp,
) -> Result<(), AllocateError> {
unreachable!("the full-drain tests only reclaim")
}
async fn consolidate(
&self,
_lease_id: LeaseId,
_fencing_token: FencingToken,
_unspent: CostUnits,
_requested: CostUnits,
_needed: CostUnits,
_ttl: SignedDuration,
_now: Timestamp,
) -> Result<Allocation, AllocateError> {
unreachable!("the reclaim drain never consolidates")
}
async fn reclaim_expired_batch(
&self,
_now: Timestamp,
limit: NonZeroUsize,
) -> Result<ReclaimBatch, StoreError> {
let call = self.calls.fetch_add(1, Ordering::AcqRel);
if matches!(self.script, ReclaimScript::FailFirst) || call > 0 {
return Err(StoreError("scripted reclaim failure".into()));
}
let reclaimed = (0..limit.get())
.map(|id| ReclaimedLease {
lease_id: LeaseId(u128::try_from(id).unwrap()),
account_id: AccountId(1),
forfeited: CostUnits(1),
})
.collect();
ReclaimBatch::try_new(reclaimed, limit)
}
}
#[test]
fn consolidation_grows_only_to_a_fundable_needed_quote() {
let policy = GrantPolicy::default();
let size = |requested, balance, floor, needed| {
policy.consolidation_grant(
CostUnits(requested),
CostUnits(balance),
CostUnits(floor),
CostUnits(needed),
)
};
assert_eq!(
size(1_000, 60, 30, 0),
Some(CostUnits(30)),
"the GL-109 floor"
);
assert_eq!(
size(1_000, 60, 30, 51),
Some(CostUnits(51)),
"proven demand"
);
assert_eq!(
size(1_000, 60, 30, 60),
Some(CostUnits(60)),
"all of it, inclusive"
);
assert_eq!(
size(1_000, 60, 30, 61),
Some(CostUnits(30)),
"an unfundable quote grows nothing"
);
assert_eq!(
size(1_000, 60, 40, 35),
Some(CostUnits(40)),
"demand never shrinks the floor"
);
assert_eq!(
size(10, 60, 0, 51),
Some(CostUnits(51)),
"past a small target"
);
assert_eq!(
size(1_000, 0, 0, 51),
None,
"an empty balance still refuses"
);
assert_eq!(size(0, 60, 0, 51), None, "a zero request still refuses");
for (requested, balance) in [(1_000, 60), (7, 60), (1_000, 1)] {
assert_eq!(
size(requested, balance, 0, 0),
policy.grant(CostUnits(requested), CostUnits(balance)),
"a plain acquire is the ordinary policy"
);
}
}
fn all() -> [AllocateError; AllocateError::COUNT] {
[
AllocateError::UnknownAccount,
AllocateError::AccountInactive,
AllocateError::InsufficientBalance,
AllocateError::InvalidTtl,
AllocateError::UnknownLease,
AllocateError::Fenced,
AllocateError::LeaseNotActive,
AllocateError::InvalidRelease,
AllocateError::Storage(StoreError("connection reset".into())),
AllocateError::BalanceExhausted(tollgate_core::BalanceExhaustion { period_end: None }),
AllocateError::BalanceInsufficient(tollgate_core::BalanceShortfall {
remaining: CostUnits(1),
period_end: None,
}),
AllocateError::BalanceOverflow,
]
}
#[test]
fn indices_cover_every_slot_exactly_once() {
let mut seen = [false; AllocateError::COUNT];
for error in all() {
let index = error.index();
assert!(index < AllocateError::COUNT, "{error} indexes out of range");
assert!(!seen[index], "{error} shares slot {index}");
seen[index] = true;
}
assert!(seen.iter().all(|hit| *hit), "every slot must be claimed");
}
#[test]
fn labels_are_distinct_and_free_of_backend_text() {
for (position, error) in all().iter().enumerate() {
assert_eq!(error.name(), AllocateError::NAMES[position]);
}
let storage = AllocateError::Storage(StoreError("connection reset".into()));
assert_eq!(storage.name(), "storage");
assert!(
!storage.name().contains("connection"),
"the label must not carry the backend's message"
);
let mut names = AllocateError::NAMES;
names.sort_unstable();
names.iter().reduce(|previous, next| {
assert_ne!(previous, next, "duplicate label {next}");
next
});
}
#[test]
fn payload_does_not_affect_the_slot() {
assert_eq!(
AllocateError::Storage(StoreError("a".into())).index(),
AllocateError::Storage(StoreError("b".into())).index()
);
}
#[test]
fn conservation_requires_an_exact_equation_without_overflow() {
let balanced = Conservation {
deposited: CostUnits(10),
overage_recorded: CostUnits::ZERO,
balance: CostUnits(1),
active_lease_grants: CostUnits(2),
settled_usage: CostUnits(3),
settlement_loss: CostUnits(4),
expired: CostUnits::ZERO,
};
assert!(balanced.holds());
let drifted = Conservation {
deposited: CostUnits(11),
..balanced
};
assert!(!drifted.holds());
let overflowing = Conservation {
deposited: CostUnits(u64::MAX),
overage_recorded: CostUnits::ZERO,
balance: CostUnits(u64::MAX),
active_lease_grants: CostUnits(1),
settled_usage: CostUnits::ZERO,
settlement_loss: CostUnits::ZERO,
expired: CostUnits::ZERO,
};
assert!(!overflowing.holds());
}
#[test]
fn overage_funds_the_usage_it_bills() {
let elastic = Conservation {
deposited: CostUnits(10),
overage_recorded: CostUnits(5),
balance: CostUnits(1),
active_lease_grants: CostUnits(2),
settled_usage: CostUnits(8),
settlement_loss: CostUnits(4),
expired: CostUnits::ZERO,
};
assert!(elastic.holds());
assert!(
!Conservation {
overage_recorded: CostUnits::ZERO,
..elastic
}
.holds(),
"the same ledger without the funding term must fail by exactly the overage"
);
}
#[test]
fn overflowing_the_funding_sum_is_a_violation_not_a_wrap() {
let overflowing = Conservation {
deposited: CostUnits(u64::MAX),
overage_recorded: CostUnits(1),
balance: CostUnits::ZERO,
active_lease_grants: CostUnits::ZERO,
settled_usage: CostUnits::ZERO,
settlement_loss: CostUnits::ZERO,
expired: CostUnits::ZERO,
};
assert!(!overflowing.holds());
}
#[test]
fn expiry_accounts_for_an_allowance_that_was_never_spent() {
let rolled = Conservation {
deposited: CostUnits(10),
overage_recorded: CostUnits::ZERO,
balance: CostUnits(1),
active_lease_grants: CostUnits(2),
settled_usage: CostUnits(3),
settlement_loss: CostUnits::ZERO,
expired: CostUnits(4),
};
assert!(rolled.holds());
assert!(
!Conservation {
expired: CostUnits::ZERO,
..rolled
}
.holds(),
"the same ledger without the expiry term must fail by exactly the expired units"
);
}
#[test]
fn rollover_batch_reports_what_it_rolled() {
let limit = NonZeroUsize::new(2).unwrap();
let rolled = |account| RolledAccount {
account_id: AccountId(account),
deposited: CostUnits(100),
expired: CostUnits::ZERO,
};
let empty = RolloverBatch::try_new(Vec::new(), limit).unwrap();
assert!(empty.is_empty());
assert_eq!(empty.len(), 0);
assert!(!empty.is_saturated());
assert!(empty.rolled().is_empty());
let partial = RolloverBatch::try_new(vec![rolled(1)], limit).unwrap();
assert!(!partial.is_empty());
assert_eq!(partial.len(), 1);
assert!(
!partial.is_saturated(),
"a partial batch is what ends the drain"
);
assert_eq!(partial.rolled(), &[rolled(1)]);
let full = RolloverBatch::try_new(vec![rolled(1), rolled(2)], limit).unwrap();
assert_eq!(full.len(), 2);
assert!(
full.is_saturated(),
"a batch at the limit means there may be more"
);
}
#[test]
fn a_rollover_batch_beyond_its_limit_is_refused() {
let rolled = |account| RolledAccount {
account_id: AccountId(account),
deposited: CostUnits(100),
expired: CostUnits::ZERO,
};
assert!(
RolloverBatch::try_new(vec![rolled(1), rolled(2)], NonZeroUsize::new(1).unwrap())
.is_err()
);
}
#[test]
fn reclaim_batch_reports_an_empty_result() {
let batch = ReclaimBatch::try_new(Vec::new(), NonZeroUsize::new(2).unwrap()).unwrap();
assert!(batch.is_empty());
assert_eq!(batch.len(), 0);
assert!(!batch.is_saturated());
assert!(batch.reclaimed().is_empty());
}
#[tokio::test]
async fn full_drain_preserves_an_initial_batch_error() {
let allocator = ScriptedReclaimer {
script: ReclaimScript::FailFirst,
calls: AtomicUsize::new(0),
};
let expected = StoreError("scripted reclaim failure".into());
assert_eq!(
allocator.reclaim_expired(Timestamp::MIN).await,
Err(expected)
);
assert_eq!(allocator.calls.load(Ordering::Acquire), 1);
}
#[tokio::test]
async fn full_drain_reports_progress_before_a_later_batch_error() {
let allocator = ScriptedReclaimer {
script: ReclaimScript::FullBatchThenFail,
calls: AtomicUsize::new(0),
};
let original = StoreError("scripted reclaim failure".into());
let error = allocator.reclaim_expired(Timestamp::MIN).await.unwrap_err();
assert_ne!(error, original);
assert!(error.0.contains("256 leases"));
assert!(error.0.contains("256 units"));
assert_eq!(allocator.calls.load(Ordering::Acquire), 2);
}
#[test]
fn the_push_capacity_warning_fires_only_above_the_channel() {
assert!(
!pushes_exceed_capacity(0),
"an empty batch is not a capacity problem"
);
assert!(!pushes_exceed_capacity(PUSH_CHANNEL_CAPACITY - 1));
assert!(
!pushes_exceed_capacity(PUSH_CHANNEL_CAPACITY),
"a batch that exactly fills the channel is still delivered"
);
assert!(
pushes_exceed_capacity(PUSH_CHANNEL_CAPACITY + 1),
"one more than the channel holds is what makes a subscriber lag"
);
}
}