Skip to main content

budget_context/
reservation.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use crate::{Budget, BudgetError, Resource};
4
5/// A cancellation-safe reservation for one resource.
6#[derive(Debug)]
7#[must_use = "dropping an active reservation releases its capacity"]
8pub struct Reservation {
9    budget: Budget,
10    resource: Resource,
11    amount: u64,
12    active: bool,
13}
14
15impl Reservation {
16    pub(crate) fn new(budget: Budget, resource: Resource, amount: u64) -> Self {
17        Self {
18            budget,
19            resource,
20            amount,
21            active: amount != 0,
22        }
23    }
24
25    /// Returns the resource held by this reservation.
26    #[must_use]
27    pub fn resource(&self) -> &Resource {
28        &self.resource
29    }
30
31    /// Returns the reserved amount.
32    #[must_use]
33    pub fn amount(&self) -> u64 {
34        self.amount
35    }
36
37    /// Reconciles actual usage and releases unused capacity.
38    ///
39    /// If `actual` exceeds the reservation, the complete reservation is
40    /// converted to consumed capacity before the error is returned. The caller
41    /// is responsible for externally constraining operations when a hard upper
42    /// bound is required.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`BudgetError::ReservationExceeded`] after retaining the full
47    /// reservation as consumed when `actual` exceeds the reserved amount.
48    pub fn commit(mut self, actual: u64) -> Result<(), BudgetError> {
49        if self.amount == 0 {
50            if actual == 0 {
51                return Ok(());
52            }
53            return Err(BudgetError::ReservationExceeded {
54                resource: self.resource.clone(),
55                reserved: 0,
56                actual,
57                unaccounted: actual,
58            });
59        }
60        let reserved = BTreeMap::from([(self.resource.clone(), self.amount)]);
61        let accounted = BTreeMap::from([(self.resource.clone(), actual.min(self.amount))]);
62        self.budget.commit_reserved(&reserved, &accounted);
63        self.active = false;
64        self.budget
65            .trace_event("commit", &self.resource, actual.min(self.amount));
66        if actual > self.amount {
67            return Err(BudgetError::ReservationExceeded {
68                resource: self.resource.clone(),
69                reserved: self.amount,
70                actual,
71                unaccounted: actual - self.amount,
72            });
73        }
74        Ok(())
75    }
76
77    /// Explicitly releases the complete reservation.
78    pub fn release(mut self) {
79        if self.active {
80            let reserved = BTreeMap::from([(self.resource.clone(), self.amount)]);
81            self.budget.release_reserved(&reserved);
82            self.active = false;
83            self.budget
84                .trace_event("release", &self.resource, self.amount);
85        }
86    }
87}
88
89impl Drop for Reservation {
90    fn drop(&mut self) {
91        if self.active {
92            let reserved = BTreeMap::from([(self.resource.clone(), self.amount)]);
93            self.budget.release_reserved(&reserved);
94            self.active = false;
95            self.budget
96                .trace_event("release", &self.resource, self.amount);
97        }
98    }
99}
100
101/// An atomic, cancellation-safe reservation for several resources.
102#[derive(Debug)]
103#[must_use = "dropping an active reservation set releases its capacity"]
104pub struct ReservationSet {
105    budget: Budget,
106    amounts: BTreeMap<Resource, u64>,
107    active: bool,
108}
109
110impl ReservationSet {
111    pub(crate) fn new(budget: Budget, amounts: BTreeMap<Resource, u64>) -> Self {
112        let active = !amounts.is_empty();
113        Self {
114            budget,
115            amounts,
116            active,
117        }
118    }
119
120    /// Returns the canonical, resource-sorted reservation amounts.
121    #[must_use]
122    pub fn amounts(&self) -> &BTreeMap<Resource, u64> {
123        &self.amounts
124    }
125
126    /// Reconciles actual usage atomically and releases unused capacity.
127    ///
128    /// Duplicate actual entries are combined with checked arithmetic. Omitted
129    /// resources have zero actual usage. Unknown resources and arithmetic
130    /// overflow fail closed by converting the full reservation set to consumed
131    /// capacity before returning an error.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error for unknown resources, duplicate-entry overflow, or
136    /// actual usage beyond a reservation. Accounting is reconciled
137    /// conservatively before every error is returned.
138    pub fn commit<'a>(
139        mut self,
140        actual: impl IntoIterator<Item = (&'a Resource, u64)>,
141    ) -> Result<(), BudgetError> {
142        let mut actuals = BTreeMap::<Resource, u64>::new();
143        let mut overflows = BTreeSet::new();
144        for (resource, amount) in actual {
145            let current = actuals.entry(resource.clone()).or_default();
146            if let Some(sum) = current.checked_add(amount) {
147                *current = sum;
148            } else {
149                overflows.insert(resource.clone());
150            }
151        }
152        if let Some(resource) = actuals
153            .keys()
154            .find(|resource| !self.amounts.contains_key(*resource))
155            .cloned()
156        {
157            self.commit_all_inner();
158            return Err(BudgetError::UnknownReservationResource { resource });
159        }
160        if let Some(resource) = overflows.into_iter().next() {
161            self.commit_all_inner();
162            return Err(BudgetError::Overflow { resource });
163        }
164
165        let accounted = self
166            .amounts
167            .iter()
168            .map(|(resource, reserved)| {
169                let actual = actuals.get(resource).copied().unwrap_or(0);
170                (resource.clone(), actual.min(*reserved))
171            })
172            .collect();
173        self.budget.commit_reserved(&self.amounts, &accounted);
174        self.active = false;
175        for (resource, amount) in &accounted {
176            self.budget.trace_event("commit", resource, *amount);
177        }
178
179        for (resource, reserved) in &self.amounts {
180            let actual = actuals.get(resource).copied().unwrap_or(0);
181            if actual > *reserved {
182                return Err(BudgetError::ReservationExceeded {
183                    resource: resource.clone(),
184                    reserved: *reserved,
185                    actual,
186                    unaccounted: actual - reserved,
187                });
188            }
189        }
190        Ok(())
191    }
192
193    /// Converts every reserved amount to consumed capacity.
194    pub fn commit_all(mut self) {
195        self.commit_all_inner();
196    }
197
198    /// Explicitly releases the complete reservation set.
199    pub fn release(mut self) {
200        if self.active {
201            self.budget.release_reserved(&self.amounts);
202            self.active = false;
203            for (resource, amount) in &self.amounts {
204                self.budget.trace_event("release", resource, *amount);
205            }
206        }
207    }
208
209    fn commit_all_inner(&mut self) {
210        if self.active {
211            self.budget.commit_reserved(&self.amounts, &self.amounts);
212            self.active = false;
213            for (resource, amount) in &self.amounts {
214                self.budget.trace_event("commit", resource, *amount);
215            }
216        }
217    }
218}
219
220impl Drop for ReservationSet {
221    fn drop(&mut self) {
222        if self.active {
223            self.budget.release_reserved(&self.amounts);
224            self.active = false;
225            for (resource, amount) in &self.amounts {
226                self.budget.trace_event("release", resource, *amount);
227            }
228        }
229    }
230}