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/host` 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::api::host::EnvironmentId;
31use crate::bindings::golem::quota::types;
32use crate::value_and_type::type_builder::TypeNodeBuilder;
33use crate::value_and_type::wasi::Datetime;
34use crate::value_and_type::{FromValueAndType, IntoValue};
35use golem_wasm::{NodeBuilder, WitValueExtractor};
36use std::time::Duration;
37
38/// Error returned when a reservation cannot be granted because the resource's
39/// enforcement policy is `reject`.
40///
41/// Contains an optional estimated wait time — only available for rate-limited
42/// resources where a future refill is predictable.
43#[derive(Debug, Clone, PartialEq)]
44pub struct FailedReservation {
45 /// How long the caller would likely need to wait for capacity, if known.
46 pub estimated_wait: Option<Duration>,
47}
48
49impl From<types::FailedReservation> for FailedReservation {
50 fn from(raw: types::FailedReservation) -> Self {
51 Self {
52 estimated_wait: raw.estimated_wait_nanos.map(Duration::from_nanos),
53 }
54 }
55}
56
57impl std::fmt::Display for FailedReservation {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self.estimated_wait {
60 Some(d) => write!(f, "quota reservation failed (retry after {d:?})"),
61 None => write!(f, "quota reservation failed"),
62 }
63 }
64}
65
66impl std::error::Error for FailedReservation {}
67
68/// A short-lived capability representing a pending resource consumption.
69///
70/// Dropping a `Reservation` without calling [`commit`][Reservation::commit] is
71/// equivalent to committing with the full reserved amount
72#[must_use]
73pub struct Reservation {
74 raw: types::Reservation,
75}
76
77impl Reservation {
78 /// Commit actual usage.
79 ///
80 /// - If `used` < reserved — unused capacity is returned to the pool.
81 /// - If `used` > reserved — the excess is deducted from the token's
82 /// remaining allocation as "debt".
83 pub fn commit(self, used: u64) {
84 types::Reservation::commit(self.raw, used);
85 }
86}
87
88/// An unforgeable capability granting the right to consume a named resource.
89///
90/// Dropping the token releases the underlying lease back to the executor pool.
91///
92/// # Example
93///
94/// ```rust,ignore
95/// let token = QuotaToken::new("llm-tokens", 1000);
96/// match token.reserve(500) {
97/// Ok(reservation) => {
98/// let used = call_llm_api();
99/// reservation.commit(used);
100/// }
101/// Err(e) => eprintln!("quota unavailable: {e}"),
102/// }
103/// ```
104pub struct QuotaToken {
105 raw: types::QuotaToken,
106}
107
108impl QuotaToken {
109 /// Request a quota capability for the given resource.
110 ///
111 /// - `resource_name`: the resource name as declared in the manifest.
112 /// - `expected_use`: expected units per reservation; used to derive the
113 /// credit rate and max-credit for fair scheduling.
114 pub fn new(resource_name: &str, expected_use: u64) -> Self {
115 Self {
116 raw: types::QuotaToken::new(resource_name, expected_use),
117 }
118 }
119
120 /// Reserve `amount` units from the local allocation.
121 ///
122 /// Blocks internally until capacity is available or the resource's
123 /// enforcement action fires. Returns a [`Reservation`] handle that
124 /// must be committed (or dropped) to release unused capacity.
125 ///
126 /// Returns `Err(FailedReservation)` when the enforcement policy is `reject`.
127 /// For `throttle` / `terminate` policies the call suspends or terminates
128 /// the agent before returning.
129 pub fn reserve(&self, amount: u64) -> Result<Reservation, FailedReservation> {
130 self.raw
131 .reserve(amount)
132 .map(|raw| Reservation { raw })
133 .map_err(FailedReservation::from)
134 }
135
136 /// Split off a child token with `child_expected_use` units.
137 ///
138 /// - The parent's expected-use is reduced by `child_expected_use`.
139 /// - Credits are divided proportionally between parent and child.
140 ///
141 /// # Panics
142 ///
143 /// Traps if `child_expected_use` exceeds the parent's current expected-use.
144 pub fn split(&mut self, child_expected_use: u64) -> QuotaToken {
145 QuotaToken {
146 raw: self.raw.split(child_expected_use),
147 }
148 }
149
150 /// Merge `other` into this token, combining expected-use and credits.
151 ///
152 /// Both tokens must refer to the same resource (same resource-name and
153 /// environment). `other` is consumed.
154 ///
155 /// # Panics
156 ///
157 /// Traps if the tokens refer to different resources.
158 pub fn merge(&mut self, other: QuotaToken) {
159 self.raw.merge(other.raw);
160 }
161
162 fn to_record(&self) -> types::QuotaTokenRecord {
163 self.raw.to_record()
164 }
165
166 fn from_record(record: &types::QuotaTokenRecord) -> QuotaToken {
167 QuotaToken {
168 raw: types::QuotaToken::from_record(record),
169 }
170 }
171}
172
173/// Reserve `amount` units, run `f`, then commit the actual usage returned by `f`.
174///
175/// `f` receives a shared reference to the [`Reservation`] (for inspection) and
176/// must return `(used, value)`.
177///
178/// Returns `Err(FailedReservation)` if the reservation could not be granted.
179///
180/// # Example
181///
182/// ```rust,ignore
183/// let result = with_reservation(&token, 500, |_res| {
184/// let data = call_external_api();
185/// (data.tokens_used, data)
186/// });
187/// ```
188pub fn with_reservation<T, F>(token: &QuotaToken, amount: u64, f: F) -> Result<T, FailedReservation>
189where
190 F: FnOnce(&Reservation) -> (u64, T),
191{
192 let reservation = token.reserve(amount)?;
193 let (used, value) = f(&reservation);
194 reservation.commit(used);
195 Ok(value)
196}
197
198impl IntoValue for QuotaToken {
199 fn add_to_builder<T: NodeBuilder>(self, builder: T) -> T::Result {
200 let record = self.to_record();
201 let builder = builder.record();
202 let builder = record.environment_id.add_to_builder(builder.item());
203 let builder = record.resource_name.add_to_builder(builder.item());
204 let builder = record.expected_use.add_to_builder(builder.item());
205 let builder = record.last_credit.add_to_builder(builder.item());
206 let builder = record.last_credit_at.add_to_builder(builder.item());
207 builder.finish()
208 }
209
210 fn add_to_type_builder<T: TypeNodeBuilder>(builder: T) -> T::Result {
211 let builder = builder.record(
212 Some("QuotaTokenRecord".to_string()),
213 Some("golem:quota".to_string()),
214 );
215 let builder = <EnvironmentId>::add_to_type_builder(builder.field("environment-id"));
216 let builder = <String>::add_to_type_builder(builder.field("resource-name"));
217 let builder = <u64>::add_to_type_builder(builder.field("expected-use"));
218 let builder = <i64>::add_to_type_builder(builder.field("last-credit"));
219 let builder = <Datetime>::add_to_type_builder(builder.field("last-credit-at"));
220 builder.finish()
221 }
222}
223
224impl FromValueAndType for QuotaToken {
225 fn from_extractor<'a, 'b>(
226 extractor: &'a impl WitValueExtractor<'a, 'b>,
227 ) -> Result<Self, String> {
228 let environment_id = <EnvironmentId>::from_extractor(
229 &extractor
230 .field(0)
231 .ok_or_else(|| "Missing environment-id".to_string())?,
232 )?;
233 let resource_name = <String>::from_extractor(
234 &extractor
235 .field(1)
236 .ok_or_else(|| "Missing resource-name".to_string())?,
237 )?;
238 let expected_use = <u64>::from_extractor(
239 &extractor
240 .field(2)
241 .ok_or_else(|| "Missing expected-use".to_string())?,
242 )?;
243 let last_credit = <i64>::from_extractor(
244 &extractor
245 .field(3)
246 .ok_or_else(|| "Missing last-credit".to_string())?,
247 )?;
248 let last_credit_at = <Datetime>::from_extractor(
249 &extractor
250 .field(4)
251 .ok_or_else(|| "Missing last-credit-at".to_string())?,
252 )?;
253 let record = types::QuotaTokenRecord {
254 environment_id,
255 resource_name,
256 expected_use,
257 last_credit,
258 last_credit_at,
259 };
260 Ok(QuotaToken::from_record(&record))
261 }
262}