Skip to main content

golem_rust/
quota.rs

1// Copyright 2024-2026 Golem Cloud
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Ergonomic wrappers for the `golem:quota/types` WIT interface.
16//!
17//! # Typical usage
18//!
19//! ```rust,ignore
20//! use golem_rust::quota::{QuotaToken, with_reservation};
21//!
22//! let token = QuotaToken::new("openai-tokens", 1000);
23//! let result = with_reservation(&token, 500, |_reservation| {
24//!     // ... call external API ...
25//!     let actual_used = 312u64;
26//!     (actual_used, my_api_result)
27//! });
28//! ```
29
30use crate::bindings::golem::quota::types;
31use crate::schema::wit::GuestQuotaTokenHandle;
32use crate::schema::wit::direct::{
33    FromWire, IntoWire, WireError, WirePreflight, WireReader, WireSchema, WireSchemaBuilder,
34    WireWriter,
35};
36use std::time::Duration;
37
38#[cfg(feature = "export_golem_agentic")]
39use crate::schema::{
40    FromSchema, FromSchemaError, IntoSchema, QuotaTokenSpec, SchemaBuilder, SchemaType,
41    SchemaValue, TypeId,
42};
43
44/// Error returned when a reservation cannot be granted because the resource's
45/// enforcement policy is `reject`.
46///
47/// Contains an optional estimated wait time — only available for rate-limited
48/// resources where a future refill is predictable.
49#[derive(Debug, Clone, PartialEq)]
50pub struct FailedReservation {
51    /// How long the caller would likely need to wait for capacity, if known.
52    pub estimated_wait: Option<Duration>,
53}
54
55impl From<types::FailedReservation> for FailedReservation {
56    fn from(raw: types::FailedReservation) -> Self {
57        Self {
58            estimated_wait: raw.estimated_wait_nanos.map(Duration::from_nanos),
59        }
60    }
61}
62
63impl std::fmt::Display for FailedReservation {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self.estimated_wait {
66            Some(d) => write!(f, "quota reservation failed (retry after {d:?})"),
67            None => write!(f, "quota reservation failed"),
68        }
69    }
70}
71
72impl std::error::Error for FailedReservation {}
73
74/// A short-lived capability representing a pending resource consumption.
75///
76/// Dropping a `Reservation` without calling [`commit`][Reservation::commit] is
77/// equivalent to committing with the full reserved amount
78#[must_use]
79pub struct Reservation {
80    raw: types::Reservation,
81}
82
83impl Reservation {
84    /// Commit actual usage.
85    ///
86    /// - If `used` < reserved — unused capacity is returned to the pool.
87    /// - If `used` > reserved — the excess is deducted from the token's
88    ///   remaining allocation as "debt".
89    pub fn commit(self, used: u64) {
90        types::Reservation::commit(self.raw, used);
91    }
92}
93
94/// An unforgeable capability granting the right to consume a named resource.
95///
96/// Dropping the token releases the underlying lease back to the executor pool.
97///
98/// # Example
99///
100/// ```rust,ignore
101/// let token = QuotaToken::new("llm-tokens", 1000);
102/// match token.reserve(500) {
103///     Ok(reservation) => {
104///         let used = call_llm_api();
105///         reservation.commit(used);
106///     }
107///     Err(e) => eprintln!("quota unavailable: {e}"),
108/// }
109/// ```
110pub struct QuotaToken {
111    handle: GuestQuotaTokenHandle,
112}
113
114impl QuotaToken {
115    /// Request a quota capability for the given resource.
116    ///
117    /// - `resource_name`: the resource name as declared in the manifest.
118    /// - `expected_use`: expected units per reservation; used to derive the
119    ///   credit rate and max-credit for fair scheduling.
120    pub fn new(resource_name: &str, expected_use: u64) -> Self {
121        Self {
122            handle: GuestQuotaTokenHandle::new(types::new_token(resource_name, expected_use)),
123        }
124    }
125
126    /// Reserve `amount` units from the local allocation.
127    ///
128    /// Blocks internally until capacity is available or the resource's
129    /// enforcement action fires.  Returns a [`Reservation`] handle that
130    /// must be committed (or dropped) to release unused capacity.
131    ///
132    /// Returns `Err(FailedReservation)` when the enforcement policy is `reject`.
133    /// For `throttle` / `terminate` policies the call suspends or terminates
134    /// the agent before returning.
135    ///
136    /// # Panics
137    ///
138    /// Traps if this token has already been transferred (for example, sent to
139    /// another agent through an RPC call or returned from a method). Split the
140    /// token first if you need to both keep and send a capability.
141    pub fn reserve(&self, amount: u64) -> Result<Reservation, FailedReservation> {
142        self.handle
143            .with_handle(|raw| types::reserve(raw, amount))
144            .unwrap_or_else(|| panic!("{TOKEN_CONSUMED}"))
145            .map(|raw| Reservation { raw })
146            .map_err(FailedReservation::from)
147    }
148
149    /// Split off a child token with `child_expected_use` units.
150    ///
151    /// - The parent's expected-use is reduced by `child_expected_use`.
152    /// - Credits are divided proportionally between parent and child.
153    ///
154    /// # Panics
155    ///
156    /// Traps if `child_expected_use` exceeds the parent's current expected-use,
157    /// or if this token has already been transferred.
158    pub fn split(&mut self, child_expected_use: u64) -> QuotaToken {
159        let raw = self
160            .handle
161            .with_handle(|raw| types::split(raw, child_expected_use))
162            .unwrap_or_else(|| panic!("{TOKEN_CONSUMED}"));
163        QuotaToken {
164            handle: GuestQuotaTokenHandle::new(raw),
165        }
166    }
167
168    /// Merge `other` into this token, combining expected-use and credits.
169    ///
170    /// Both tokens must refer to the same resource (same resource-name and
171    /// environment).  `other` is consumed.
172    ///
173    /// # Panics
174    ///
175    /// Traps if the tokens refer to different resources, or if either token has
176    /// already been transferred.
177    pub fn merge(&mut self, other: QuotaToken) {
178        // Reject merging a token into itself before taking any handle, so a
179        // shared handle is not consumed as `other` and then read again as the
180        // receiver.
181        if self.handle.cell_id() == other.handle.cell_id() {
182            panic!("cannot merge a quota token with itself");
183        }
184        // Check the receiver first so an already-transferred receiver does not
185        // consume `other`.
186        if !self.handle.is_present() {
187            panic!("{TOKEN_CONSUMED}");
188        }
189        let other_raw = other
190            .handle
191            .take()
192            .unwrap_or_else(|| panic!("{TOKEN_CONSUMED}"));
193        self.handle
194            .with_handle(|raw| types::merge(raw, other_raw))
195            .unwrap_or_else(|| panic!("{TOKEN_CONSUMED}"));
196    }
197}
198
199const TOKEN_CONSUMED: &str = "quota token has already been transferred and can no longer be used; split the token first if \
200     you need to both keep and send a capability";
201
202impl WireSchema for QuotaToken {
203    fn wire_type_id() -> String {
204        "golem.core.QuotaToken".to_string()
205    }
206
207    fn append_schema(builder: &mut WireSchemaBuilder) -> i32 {
208        GuestQuotaTokenHandle::append_schema(builder)
209    }
210}
211
212impl FromWire for QuotaToken {
213    fn read_wire(reader: &mut WireReader, index: i32) -> Result<Self, WireError> {
214        Ok(Self {
215            handle: GuestQuotaTokenHandle::read_wire(reader, index)?,
216        })
217    }
218}
219
220impl IntoWire for QuotaToken {
221    fn preflight(&self, resources: &mut WirePreflight) -> Result<(), WireError> {
222        self.handle.preflight(resources)
223    }
224
225    fn write_wire(&self, writer: &mut WireWriter) -> Result<i32, WireError> {
226        self.handle.write_wire(writer)
227    }
228}
229
230#[cfg(feature = "export_golem_agentic")]
231impl IntoSchema for QuotaToken {
232    fn type_id() -> TypeId {
233        TypeId::new("golem.core.QuotaToken")
234    }
235
236    fn register_in(_builder: &mut SchemaBuilder) -> SchemaType {
237        SchemaType::quota_token(QuotaTokenSpec::default())
238    }
239
240    /// Lower the token into a schema value by sharing its opaque owned handle.
241    ///
242    /// The handle is not transferred here; it is moved out of the cell only when
243    /// the resulting [`SchemaValue`] is encoded into a WIT `schema-value-tree`.
244    fn to_value(&self) -> SchemaValue {
245        SchemaValue::QuotaToken(self.handle.clone())
246    }
247}
248
249#[cfg(feature = "export_golem_agentic")]
250impl FromSchema for QuotaToken {
251    fn from_value(value: &SchemaValue) -> Result<Self, FromSchemaError> {
252        match value {
253            SchemaValue::QuotaToken(handle) => Ok(QuotaToken {
254                handle: handle.clone(),
255            }),
256            other => Err(FromSchemaError::shape_mismatch(
257                "quota-token",
258                format!("{other:?}"),
259                "QuotaToken",
260            )),
261        }
262    }
263}
264
265/// Reserve `amount` units, run `f`, then commit the actual usage returned by `f`.
266///
267/// `f` receives a shared reference to the [`Reservation`] (for inspection) and
268/// must return `(used, value)`.
269///
270/// Returns `Err(FailedReservation)` if the reservation could not be granted.
271///
272/// # Example
273///
274/// ```rust,ignore
275/// let result = with_reservation(&token, 500, |_res| {
276///     let data = call_external_api();
277///     (data.tokens_used, data)
278/// });
279/// ```
280pub fn with_reservation<T, F>(token: &QuotaToken, amount: u64, f: F) -> Result<T, FailedReservation>
281where
282    F: FnOnce(&Reservation) -> (u64, T),
283{
284    let reservation = token.reserve(amount)?;
285    let (used, value) = f(&reservation);
286    reservation.commit(used);
287    Ok(value)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::schema::wit::{direct, wire};
294    use test_r::test;
295
296    #[test]
297    fn direct_quota_preflight_is_atomic_and_transfer_is_affine() {
298        let first = GuestQuotaTokenHandle::new(unsafe { types::QuotaToken::from_handle(17) });
299        let second = GuestQuotaTokenHandle::new(unsafe { types::QuotaToken::from_handle(29) });
300        let tokens = vec![
301            QuotaToken {
302                handle: first.clone(),
303            },
304            QuotaToken {
305                handle: second.clone(),
306            },
307            QuotaToken {
308                handle: second.clone(),
309            },
310        ];
311        assert!(matches!(
312            direct::encode(&tokens),
313            Err(WireError::AliasedResource(_))
314        ));
315        assert!(first.is_present());
316        assert!(second.is_present());
317
318        let tokens = (
319            QuotaToken {
320                handle: first.clone(),
321            },
322            QuotaToken {
323                handle: second.clone(),
324            },
325        );
326        let tree = direct::encode(&tokens).unwrap();
327        assert!(!first.is_present());
328        assert!(!second.is_present());
329        assert!(matches!(
330            direct::encode(&tokens),
331            Err(WireError::ConsumedResource(_))
332        ));
333        let decoded = direct::decode::<(QuotaToken, QuotaToken)>(tree).unwrap();
334        let forwarded = direct::encode(&decoded).unwrap();
335        let handles = forwarded
336            .value_nodes
337            .into_iter()
338            .filter_map(|node| match node {
339                wire::SchemaValueNode::QuotaTokenHandle(handle) => Some(handle.take_handle()),
340                _ => None,
341            })
342            .collect::<Vec<_>>();
343        assert_eq!(handles, vec![17, 29]);
344    }
345}