Skip to main content

budget_context/
budget.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::{Arc, Mutex, MutexGuard};
4use std::time::{Duration, Instant};
5
6#[cfg(feature = "tokio")]
7use tokio_util::sync::CancellationToken;
8
9use crate::{
10    BudgetBuildError, BudgetError, BudgetId, BudgetSnapshot, Remaining, Reservation,
11    ReservationSet, Resource, ResourceSnapshot,
12};
13
14static NEXT_BUDGET_ID: AtomicU64 = AtomicU64::new(1);
15
16#[derive(Clone, Copy, Debug, Default)]
17pub(crate) struct Usage {
18    pub(crate) consumed: u64,
19    pub(crate) reserved: u64,
20}
21
22#[derive(Debug, Default)]
23pub(crate) struct NodeState {
24    pub(crate) usage: BTreeMap<Resource, Usage>,
25}
26
27#[derive(Debug)]
28pub(crate) struct Node {
29    pub(crate) id: BudgetId,
30    pub(crate) name: Option<Arc<str>>,
31    pub(crate) parent: Option<Arc<Node>>,
32    pub(crate) limits: BTreeMap<Resource, u64>,
33    pub(crate) state: Mutex<NodeState>,
34    pub(crate) deadline: Option<Instant>,
35    #[cfg(feature = "tokio")]
36    pub(crate) cancellation: CancellationToken,
37}
38
39/// A cloneable handle to one node in a hierarchical resource budget.
40#[derive(Clone, Debug)]
41pub struct Budget {
42    pub(crate) node: Arc<Node>,
43}
44
45/// Builder for a root or child [`Budget`].
46#[derive(Debug)]
47pub struct BudgetBuilder {
48    parent: Option<Budget>,
49    name: Option<Arc<str>>,
50    limits: BTreeMap<Resource, u64>,
51    duplicate_limit: Option<Resource>,
52    deadline: Option<DeadlineRequest>,
53    duplicate_deadline: bool,
54}
55
56#[derive(Clone, Copy, Debug)]
57enum DeadlineRequest {
58    At(Instant),
59    After(Duration),
60}
61
62impl Budget {
63    /// Starts a root budget builder.
64    #[must_use]
65    pub fn builder() -> BudgetBuilder {
66        BudgetBuilder::new(None)
67    }
68
69    /// Starts a child builder whose operations will also count against this node.
70    #[must_use]
71    pub fn child(&self) -> BudgetBuilder {
72        BudgetBuilder::new(Some(self.clone()))
73    }
74
75    /// Returns this node's process-local identifier.
76    #[must_use]
77    pub fn id(&self) -> BudgetId {
78        self.node.id
79    }
80
81    /// Immediately and atomically consumes an amount across the entire lineage.
82    ///
83    /// # Errors
84    ///
85    /// Returns an error when the request overflows, exceeds a limit, or begins
86    /// after the effective deadline or cancellation request.
87    pub fn consume(&self, resource: &Resource, amount: u64) -> Result<(), BudgetError> {
88        if amount == 0 {
89            return Ok(());
90        }
91        self.ensure_active()?;
92        let requests = BTreeMap::from([(resource.clone(), amount)]);
93        self.apply(&requests, AccountingKind::Consumed)?;
94        self.trace_event("consume", resource, amount);
95        Ok(())
96    }
97
98    /// Atomically reserves capacity for one resource.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when the request overflows, exceeds a limit, or begins
103    /// after the effective deadline or cancellation request.
104    pub fn reserve(&self, resource: &Resource, amount: u64) -> Result<Reservation, BudgetError> {
105        if amount != 0 {
106            self.ensure_active()?;
107            let requests = BTreeMap::from([(resource.clone(), amount)]);
108            self.apply(&requests, AccountingKind::Reserved)?;
109            self.trace_event("reserve", resource, amount);
110        }
111        Ok(Reservation::new(self.clone(), resource.clone(), amount))
112    }
113
114    /// Atomically reserves several resources, combining duplicate entries.
115    ///
116    /// The input is canonicalized by resource name. Overflow or exhaustion
117    /// changes no accounting state.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error when canonicalization overflows, a request exceeds a
122    /// limit, or the operation begins after the effective deadline or
123    /// cancellation request.
124    pub fn reserve_many<'a>(
125        &self,
126        resources: impl IntoIterator<Item = (&'a Resource, u64)>,
127    ) -> Result<ReservationSet, BudgetError> {
128        let mut requests = BTreeMap::<Resource, u64>::new();
129        let mut overflows = BTreeSet::new();
130        for (resource, amount) in resources {
131            if amount == 0 {
132                continue;
133            }
134            let current = requests.entry(resource.clone()).or_default();
135            if let Some(sum) = current.checked_add(amount) {
136                *current = sum;
137            } else {
138                overflows.insert(resource.clone());
139            }
140        }
141        if let Some(resource) = overflows.into_iter().next() {
142            return Err(BudgetError::Overflow { resource });
143        }
144        if !requests.is_empty() {
145            self.ensure_active()?;
146            self.apply(&requests, AccountingKind::Reserved)?;
147            #[cfg(feature = "tracing")]
148            tracing::event!(
149                tracing::Level::TRACE,
150                budget.id = self.id().get(),
151                budget.resources = requests.len(),
152                "budget.reserve_many"
153            );
154        }
155        Ok(ReservationSet::new(self.clone(), requests))
156    }
157
158    /// Returns effective remaining capacity at one consistent instant.
159    ///
160    /// The result is observational. Only [`Budget::consume`],
161    /// [`Budget::reserve`], and [`Budget::reserve_many`] authorize work.
162    #[must_use]
163    pub fn remaining(&self, resource: &Resource) -> Remaining {
164        let lineage = self.lineage();
165        let guards = lock_lineage(&lineage);
166        effective_remaining(&lineage, &guards, resource)
167    }
168
169    /// Captures a consistent snapshot across this node's complete lineage.
170    #[must_use]
171    pub fn snapshot(&self) -> BudgetSnapshot {
172        let lineage = self.lineage();
173        let guards = lock_lineage(&lineage);
174        let leaf_index = lineage.len() - 1;
175        let leaf = &lineage[leaf_index];
176        let leaf_state = &guards[leaf_index];
177
178        let mut resources = BTreeSet::new();
179        for node in &lineage {
180            resources.extend(node.limits.keys().cloned());
181        }
182        resources.extend(leaf_state.usage.keys().cloned());
183
184        let resources = resources
185            .into_iter()
186            .map(|resource| {
187                let usage = leaf_state.usage.get(&resource).copied().unwrap_or_default();
188                ResourceSnapshot {
189                    local_limit: leaf.limits.get(&resource).copied(),
190                    effective_remaining: effective_remaining(&lineage, &guards, &resource),
191                    resource,
192                    consumed: usage.consumed,
193                    reserved: usage.reserved,
194                }
195            })
196            .collect();
197
198        BudgetSnapshot {
199            id: leaf.id,
200            name: leaf.name.clone(),
201            resources,
202            deadline_remaining: leaf
203                .deadline
204                .map(|deadline| deadline.saturating_duration_since(Instant::now())),
205            #[cfg(feature = "tokio")]
206            cancelled: leaf.cancellation.is_cancelled(),
207        }
208    }
209
210    pub(crate) fn release_reserved(&self, resources: &BTreeMap<Resource, u64>) {
211        if resources.is_empty() {
212            return;
213        }
214        let lineage = self.lineage();
215        let mut guards = lock_lineage(&lineage);
216        for guard in &mut guards {
217            for (resource, amount) in resources {
218                if let Some(usage) = guard.usage.get_mut(resource) {
219                    usage.reserved = usage.reserved.saturating_sub(*amount);
220                }
221            }
222        }
223    }
224
225    pub(crate) fn commit_reserved(
226        &self,
227        reserved: &BTreeMap<Resource, u64>,
228        actual: &BTreeMap<Resource, u64>,
229    ) {
230        if reserved.is_empty() {
231            return;
232        }
233        let lineage = self.lineage();
234        let mut guards = lock_lineage(&lineage);
235        for guard in &mut guards {
236            for (resource, reserved_amount) in reserved {
237                if let Some(usage) = guard.usage.get_mut(resource) {
238                    let actual_amount = actual.get(resource).copied().unwrap_or(0);
239                    usage.reserved = usage.reserved.saturating_sub(*reserved_amount);
240                    usage.consumed += actual_amount.min(*reserved_amount);
241                }
242            }
243        }
244    }
245
246    fn apply(
247        &self,
248        requests: &BTreeMap<Resource, u64>,
249        kind: AccountingKind,
250    ) -> Result<(), BudgetError> {
251        let lineage = self.lineage();
252        let mut guards = lock_lineage(&lineage);
253
254        for (index, node) in lineage.iter().enumerate() {
255            for (resource, amount) in requests {
256                let usage = guards[index]
257                    .usage
258                    .get(resource)
259                    .copied()
260                    .unwrap_or_default();
261                let next = usage
262                    .consumed
263                    .checked_add(usage.reserved)
264                    .and_then(|used| used.checked_add(*amount))
265                    .ok_or_else(|| BudgetError::Overflow {
266                        resource: resource.clone(),
267                    })?;
268                if let Some(limit) = node.limits.get(resource) {
269                    if next > *limit {
270                        return Err(BudgetError::Exhausted {
271                            resource: resource.clone(),
272                            requested: *amount,
273                            remaining: limit - usage.consumed - usage.reserved,
274                            scope: node.id,
275                            scope_name: node.name.clone(),
276                        });
277                    }
278                }
279            }
280        }
281
282        for guard in &mut guards {
283            for (resource, amount) in requests {
284                let usage = guard.usage.entry(resource.clone()).or_default();
285                match kind {
286                    AccountingKind::Consumed => usage.consumed += amount,
287                    AccountingKind::Reserved => usage.reserved += amount,
288                }
289            }
290        }
291        Ok(())
292    }
293
294    pub(crate) fn ensure_active(&self) -> Result<(), BudgetError> {
295        #[cfg(feature = "tokio")]
296        if self.node.cancellation.is_cancelled() {
297            return Err(BudgetError::Cancelled);
298        }
299        if self
300            .node
301            .deadline
302            .is_some_and(|deadline| Instant::now() >= deadline)
303        {
304            return Err(BudgetError::DeadlineExceeded);
305        }
306        Ok(())
307    }
308
309    pub(crate) fn lineage(&self) -> Vec<Arc<Node>> {
310        let mut lineage = Vec::new();
311        let mut current = Some(self.node.clone());
312        while let Some(node) = current.take() {
313            current.clone_from(&node.parent);
314            lineage.push(node);
315        }
316        lineage.reverse();
317        lineage
318    }
319
320    pub(crate) fn trace_event(&self, operation: &'static str, resource: &Resource, amount: u64) {
321        #[cfg(feature = "tracing")]
322        tracing::event!(
323            tracing::Level::TRACE,
324            budget.id = self.id().get(),
325            budget.operation = operation,
326            budget.resource = resource.as_str(),
327            budget.amount = amount,
328            "budget accounting operation"
329        );
330        #[cfg(not(feature = "tracing"))]
331        let _ = (self, operation, resource, amount);
332    }
333}
334
335impl BudgetBuilder {
336    fn new(parent: Option<Budget>) -> Self {
337        Self {
338            parent,
339            name: None,
340            limits: BTreeMap::new(),
341            duplicate_limit: None,
342            deadline: None,
343            duplicate_deadline: false,
344        }
345    }
346
347    /// Assigns an optional diagnostic name.
348    #[must_use]
349    pub fn name(mut self, name: impl Into<Arc<str>>) -> Self {
350        self.name = Some(name.into());
351        self
352    }
353
354    /// Adds a local resource limit.
355    ///
356    /// Configuring the same resource twice makes [`BudgetBuilder::build`] fail.
357    #[must_use]
358    pub fn limit(mut self, resource: Resource, amount: u64) -> Self {
359        if self.limits.insert(resource.clone(), amount).is_some() && self.duplicate_limit.is_none()
360        {
361            self.duplicate_limit = Some(resource);
362        }
363        self
364    }
365
366    /// Sets an absolute monotonic deadline.
367    #[must_use]
368    pub fn deadline_at(mut self, deadline: Instant) -> Self {
369        self.set_deadline(DeadlineRequest::At(deadline));
370        self
371    }
372
373    /// Sets a deadline relative to the instant at which the budget is built.
374    #[must_use]
375    pub fn deadline_after(mut self, duration: Duration) -> Self {
376        self.set_deadline(DeadlineRequest::After(duration));
377        self
378    }
379
380    /// Builds the node and derives its effective deadline and cancellation state.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error for duplicate limits, duplicate or unrepresentable
385    /// deadlines, or exhaustion of process-local budget identifiers.
386    pub fn build(self) -> Result<Budget, BudgetBuildError> {
387        if let Some(resource) = self.duplicate_limit {
388            return Err(BudgetBuildError::DuplicateLimit { resource });
389        }
390        if self.duplicate_deadline {
391            return Err(BudgetBuildError::DuplicateDeadline);
392        }
393
394        let requested_deadline = match self.deadline {
395            None => None,
396            Some(DeadlineRequest::At(deadline)) => Some(deadline),
397            Some(DeadlineRequest::After(duration)) => Some(
398                Instant::now()
399                    .checked_add(duration)
400                    .ok_or(BudgetBuildError::DeadlineOverflow)?,
401            ),
402        };
403        let inherited_deadline = self.parent.as_ref().and_then(|parent| parent.node.deadline);
404        let deadline = match (inherited_deadline, requested_deadline) {
405            (Some(parent), Some(child)) => Some(parent.min(child)),
406            (Some(parent), None) => Some(parent),
407            (None, child) => child,
408        };
409
410        let id = NEXT_BUDGET_ID
411            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
412            .map(BudgetId)
413            .map_err(|_| BudgetBuildError::BudgetIdExhausted)?;
414
415        #[cfg(feature = "tokio")]
416        let cancellation = self
417            .parent
418            .as_ref()
419            .map_or_else(CancellationToken::new, |parent| {
420                parent.node.cancellation.child_token()
421            });
422
423        Ok(Budget {
424            node: Arc::new(Node {
425                id,
426                name: self.name,
427                parent: self.parent.map(|parent| parent.node),
428                limits: self.limits,
429                state: Mutex::new(NodeState::default()),
430                deadline,
431                #[cfg(feature = "tokio")]
432                cancellation,
433            }),
434        })
435    }
436
437    fn set_deadline(&mut self, deadline: DeadlineRequest) {
438        if self.deadline.replace(deadline).is_some() {
439            self.duplicate_deadline = true;
440        }
441    }
442}
443
444#[derive(Clone, Copy)]
445enum AccountingKind {
446    Consumed,
447    Reserved,
448}
449
450fn lock_lineage(lineage: &[Arc<Node>]) -> Vec<MutexGuard<'_, NodeState>> {
451    lineage
452        .iter()
453        .map(|node| {
454            node.state
455                .lock()
456                .unwrap_or_else(std::sync::PoisonError::into_inner)
457        })
458        .collect()
459}
460
461fn effective_remaining(
462    lineage: &[Arc<Node>],
463    guards: &[MutexGuard<'_, NodeState>],
464    resource: &Resource,
465) -> Remaining {
466    let mut effective = None;
467    for (node, state) in lineage.iter().zip(guards) {
468        if let Some(limit) = node.limits.get(resource) {
469            let usage = state.usage.get(resource).copied().unwrap_or_default();
470            let remaining = limit - usage.consumed - usage.reserved;
471            effective = Some(effective.map_or(remaining, |current: u64| current.min(remaining)));
472        }
473    }
474    effective.map_or(Remaining::Unlimited, Remaining::Limited)
475}