cratefield_core/scheduled.rs
1//! The cooperative budget a scheduled invocation runs under (issue #537).
2//!
3//! A Worker's scheduled event has a fixed CPU and subrequest allowance, and
4//! `serve_scheduled` fans one event out to every module serially — so one
5//! module polling many third-party accounts per tick can spend the whole
6//! invocation before the last module runs. Two primitives close that gap:
7//!
8//! - [`ScheduledBudget`] — what one module may spend in one invocation. It
9//! is **cooperative**: the runtime never cancels a module's work, it hands
10//! the module (via `ModuleContext::scheduled`) and the [`Outbox`](crate::Outbox)
11//! drain a budget to check before each unit of work.
12//! - [`ScheduledSplit`] — how a runtime divides one invocation's
13//! [`ScheduledLimits`] across the modules in order, with each module's
14//! unspent share rolling forward to the modules after it.
15//!
16//! The native runtime has no Workers allowance, so it hands every module an
17//! unbounded budget: `ctx.scheduled` exists on every runtime and simply
18//! never runs out off Workers.
19
20use std::sync::atomic::{AtomicU32, Ordering};
21
22use time::{Duration, OffsetDateTime};
23
24/// What one module may spend in one scheduled invocation.
25///
26/// Cooperative by design: nothing here cancels or aborts work. A module
27/// checks [`try_spend`](ScheduledBudget::try_spend) before each unit of
28/// work (one subrequest-shaped step) and stops when it answers `false`; a
29/// runtime hands a fresh budget to every module through
30/// `ModuleContext::scheduled`. Outside a scheduled invocation the budget is
31/// unbounded, so a module that checks it unconditionally pays nothing.
32#[derive(Debug)]
33pub struct ScheduledBudget {
34 /// Wall-clock end of this module's share, if the invocation bounds time.
35 deadline: Option<OffsetDateTime>,
36 /// Subrequest-shaped steps this budget may spend in total, if bounded.
37 subrequests: Option<u32>,
38 /// Steps already spent (see [`try_spend`](ScheduledBudget::try_spend)).
39 spent: AtomicU32,
40}
41
42impl ScheduledBudget {
43 /// A budget that never runs out: no deadline, no subrequest cap. This is
44 /// what a module sees outside `Module::scheduled` and on runtimes with
45 /// no invocation limits.
46 #[must_use]
47 pub const fn unbounded() -> Self {
48 Self {
49 deadline: None,
50 subrequests: None,
51 spent: AtomicU32::new(0),
52 }
53 }
54
55 /// A budget ending at `deadline` (if bounded) after `subrequests`
56 /// steps (if bounded). Either bound may be absent.
57 #[must_use]
58 pub const fn new(deadline: Option<OffsetDateTime>, subrequests: Option<u32>) -> Self {
59 Self {
60 deadline,
61 subrequests,
62 spent: AtomicU32::new(0),
63 }
64 }
65
66 /// The wall-clock end of this budget, if the invocation bounds time.
67 #[must_use]
68 pub fn deadline(&self) -> Option<OffsetDateTime> {
69 self.deadline
70 }
71
72 /// Steps this budget still has, or `None` when unbounded.
73 #[must_use]
74 pub fn remaining_subrequests(&self) -> Option<u32> {
75 self.subrequests.map(|cap| cap.saturating_sub(self.spent()))
76 }
77
78 /// Steps spent so far. Never decreases.
79 #[must_use]
80 pub fn spent(&self) -> u32 {
81 self.spent.load(Ordering::Relaxed)
82 }
83
84 /// Spends `n` steps and answers `true`, or spends nothing and answers
85 /// `false` when fewer than `n` remain. The one call a module needs
86 /// around each unit of work:
87 ///
88 /// ```ignore
89 /// while let Some(account) = next_account() {
90 /// if !ctx.scheduled.try_spend(1) { break; }
91 /// poll(account).await;
92 /// }
93 /// ```
94 pub fn try_spend(&self, n: u32) -> bool {
95 let Some(cap) = self.subrequests else {
96 self.spent.fetch_add(n, Ordering::Relaxed);
97 return true;
98 };
99 let mut seen = self.spent.load(Ordering::Relaxed);
100 loop {
101 let Some(next) = seen.checked_add(n) else {
102 return false;
103 };
104 if next > cap {
105 return false;
106 }
107 match self
108 .spent
109 .compare_exchange_weak(seen, next, Ordering::Relaxed, Ordering::Relaxed)
110 {
111 // The budget is module-private and single-tasked, so a
112 // lost race cannot happen today; the loop keeps `try_spend`
113 // honest anyway if that ever changes.
114 Ok(_) => return true,
115 Err(now_seen) => seen = now_seen,
116 }
117 }
118 }
119
120 /// `true` once `now` has passed this budget's deadline. Unbounded is
121 /// never expired.
122 #[must_use]
123 pub fn expired(&self, now: OffsetDateTime) -> bool {
124 self.deadline.is_some_and(|deadline| now >= deadline)
125 }
126
127 /// `true` when the budget has nothing left: past the deadline, or zero
128 /// subrequests remaining. The check the runtime and
129 /// [`Outbox`](crate::Outbox) drains make before each unit of work.
130 #[must_use]
131 pub fn exhausted(&self, now: OffsetDateTime) -> bool {
132 self.expired(now) || self.remaining_subrequests() == Some(0)
133 }
134}
135
136impl Default for ScheduledBudget {
137 fn default() -> Self {
138 Self::unbounded()
139 }
140}
141
142/// The limits one scheduled invocation may spend in total: how long it may
143/// run, and how many subrequest-shaped steps it may make. `None` is
144/// unbounded, which is the only value the native runtime needs —
145/// [`UNBOUNDED`](ScheduledLimits::UNBOUNDED).
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ScheduledLimits {
148 /// Wall-clock length of the whole invocation.
149 pub wall: Option<Duration>,
150 /// Subrequest-shaped steps the whole invocation may make.
151 pub subrequests: Option<u32>,
152}
153
154impl ScheduledLimits {
155 /// No limits at all: what the native cron fan-out runs with.
156 pub const UNBOUNDED: Self = Self {
157 wall: None,
158 subrequests: None,
159 };
160}
161
162/// Divides one invocation's [`ScheduledLimits`] across its modules, in
163/// order, with every module's unspent share rolling forward to the ones
164/// after it.
165///
166/// The split is even *by construction, not by outcome*: module `n` of `m`
167/// gets the wall time that is left divided by the modules still to run, and
168/// the subrequests still left divided the same way (rounded up). The
169/// invariant is on **spend**, not on the shares: shares overlap, because an
170/// unspent share rolls forward and is granted again to the modules after
171/// it, but every subrequest a module actually spends is subtracted from the
172/// pool at [`settle`](ScheduledSplit::settle) — so cumulative spend never
173/// passes the limit. A module that spends nothing simply leaves its share
174/// in the pool for the next module.
175#[derive(Debug)]
176pub struct ScheduledSplit {
177 /// When the whole invocation must end, if bounded.
178 end: Option<OffsetDateTime>,
179 /// Subrequests still unclaimed by any module, if bounded.
180 subrequests_left: Option<u32>,
181 /// Modules still waiting for a budget.
182 modules_left: usize,
183}
184
185impl ScheduledSplit {
186 /// A split of `limits` across `modules` modules, starting at `now`.
187 /// `now` is read only when `limits` bounds wall time.
188 #[must_use]
189 pub fn new(limits: ScheduledLimits, now: OffsetDateTime, modules: usize) -> Self {
190 Self {
191 end: limits.wall.map(|wall| now.saturating_add(wall)),
192 subrequests_left: limits.subrequests,
193 modules_left: modules,
194 }
195 }
196
197 /// The budget for the next module: the time left divided by the modules
198 /// still to run, and the subrequests left divided the same way (rounded
199 /// up, so a share never names more steps than the pool holds, and the
200 /// last module is left with at least what remains). Called once per
201 /// module, in order.
202 #[must_use]
203 pub fn next(&mut self, now: OffsetDateTime) -> ScheduledBudget {
204 if self.modules_left == 0 {
205 // More `next` calls than modules: a caller bug, answered with a
206 // budget that is already out rather than a silently unbounded one.
207 return ScheduledBudget::new(Some(now), Some(0));
208 }
209 let deadline = self.end.map(|end| {
210 let remaining = (end - now).max(Duration::ZERO);
211 let share = i32::try_from(self.modules_left).unwrap_or(i32::MAX);
212 now.saturating_add(remaining / share)
213 });
214 let subrequests = self.subrequests_left.map(|left| {
215 let modules = u32::try_from(self.modules_left).unwrap_or(u32::MAX);
216 left.div_ceil(modules)
217 });
218 ScheduledBudget::new(deadline, subrequests)
219 }
220
221 /// Accounts for the module that just ran: its unspent subrequests roll
222 /// back into the pool for the modules still to come, and one fewer
223 /// module is waiting. Unspent wall time needs no accounting — the next
224 /// [`next`](ScheduledSplit::next) recomputes it from the clock.
225 pub fn settle(&mut self, budget: &ScheduledBudget) {
226 self.modules_left = self.modules_left.saturating_sub(1);
227 if let Some(left) = self.subrequests_left {
228 self.subrequests_left = Some(left.saturating_sub(budget.spent()));
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 fn at(epoch_secs: i64) -> OffsetDateTime {
238 OffsetDateTime::from_unix_timestamp(epoch_secs).expect("in range")
239 }
240
241 #[test]
242 fn try_spend_spends_only_what_fits() {
243 let budget = ScheduledBudget::new(None, Some(3));
244 assert!(budget.try_spend(2));
245 assert_eq!(budget.spent(), 2);
246 assert!(!budget.try_spend(2), "a spend past the cap spends nothing");
247 assert_eq!(budget.spent(), 2, "a refused spend leaves no trace");
248 assert!(budget.try_spend(1));
249 assert_eq!(budget.remaining_subrequests(), Some(0));
250 assert!(!budget.try_spend(1), "an empty budget refuses");
251 }
252
253 #[test]
254 fn an_unbounded_budget_never_refuses_or_exhausts() {
255 let budget = ScheduledBudget::unbounded();
256 assert!(budget.try_spend(10_000));
257 assert_eq!(budget.remaining_subrequests(), None);
258 assert!(!budget.expired(at(1_000_000_000)));
259 assert!(!budget.exhausted(at(1_000_000_000)));
260 }
261
262 #[test]
263 fn expiry_is_the_deadline_and_exhaustion_is_either_bound() {
264 let timed = ScheduledBudget::new(Some(at(100)), Some(10));
265 assert!(!timed.expired(at(99)));
266 assert!(timed.expired(at(100)), "the deadline itself is past it");
267 assert!(!timed.exhausted(at(99)));
268 assert!(timed.exhausted(at(100)));
269
270 let counted = ScheduledBudget::new(None, Some(1));
271 assert!(!counted.exhausted(at(0)));
272 assert!(counted.try_spend(1));
273 assert!(
274 counted.exhausted(at(0)),
275 "zero subrequests left is exhausted without a deadline"
276 );
277 }
278
279 #[test]
280 fn an_unspent_share_rolls_forward_to_the_later_modules() {
281 let start = at(0);
282 let mut split = ScheduledSplit::new(
283 ScheduledLimits {
284 wall: None,
285 subrequests: Some(7),
286 },
287 start,
288 3,
289 );
290 let first = split.next(start);
291 assert_eq!(
292 first.remaining_subrequests(),
293 Some(3),
294 "a third, rounded up"
295 );
296 split.settle(&first); // spent nothing
297 let second = split.next(start);
298 assert_eq!(
299 second.remaining_subrequests(),
300 Some(4),
301 "the share that rolled forward, over two modules"
302 );
303 split.settle(&second); // spent nothing either
304 let third = split.next(start);
305 assert_eq!(
306 third.remaining_subrequests(),
307 Some(7),
308 "the last module gets everything left"
309 );
310 }
311
312 #[test]
313 fn settled_spending_shrinks_the_later_shares() {
314 let start = at(0);
315 let mut split = ScheduledSplit::new(
316 ScheduledLimits {
317 wall: None,
318 subrequests: Some(4),
319 },
320 start,
321 2,
322 );
323 let first = split.next(start);
324 assert_eq!(first.remaining_subrequests(), Some(2));
325 assert!(
326 !first.try_spend(3),
327 "a module cannot spend past its own share"
328 );
329 assert!(first.try_spend(2));
330 split.settle(&first);
331 let second = split.next(start);
332 assert_eq!(
333 second.remaining_subrequests(),
334 Some(2),
335 "what the first module left"
336 );
337 }
338
339 #[test]
340 fn the_wall_split_never_schedules_past_the_end() {
341 let start = at(0);
342 let mut split = ScheduledSplit::new(
343 ScheduledLimits {
344 wall: Some(Duration::seconds(30)),
345 subrequests: None,
346 },
347 start,
348 3,
349 );
350 let first = split.next(start);
351 assert_eq!(first.deadline(), Some(at(10)));
352 split.settle(&first);
353 // Module 1 actually ran for 9s: module 2 gets half of the 21s left —
354 // 10.5s, exact — computed from the clock, not an even third of the
355 // original wall.
356 let second = split.next(at(9));
357 assert_eq!(
358 second.deadline(),
359 Some(start + Duration::milliseconds(19_500))
360 );
361 split.settle(&second);
362 let third = split.next(at(19));
363 assert_eq!(
364 third.deadline(),
365 Some(at(30)),
366 "the last module runs to the end"
367 );
368 }
369
370 #[test]
371 fn unbounded_limits_give_unbounded_budgets() {
372 let start = at(0);
373 let mut split = ScheduledSplit::new(ScheduledLimits::UNBOUNDED, start, 2);
374 let budget = split.next(start);
375 assert_eq!(budget.deadline(), None);
376 assert_eq!(budget.remaining_subrequests(), None);
377 assert!(!budget.exhausted(at(1_000_000)));
378 split.settle(&budget);
379 }
380}