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}