Skip to main content

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}