Skip to main content

embassy_supervisor/
pool.rs

1//! Reusable elastic-pool scaling.
2//!
3//! A pool is a floor node (always-on `Terminate`) plus on-demand workers of the
4//! same task fn, scaled by a swappable [`ScalingPolicy`]. The policy is a generic
5//! type parameter (static dispatch, zero-cost), and may be stateful via interior
6//! mutability since the pool lives in a `static`.
7//!
8//! Heterogeneous pools are driven uniformly through the object-safe [`Pool`]
9//! trait. Its methods are **synchronous** — the policy only *decides* (returns a
10//! [`PoolAction`]); the supervisor performs the actual async `start_node` /
11//! `stop_node`. That keeps `&dyn Pool` object-safe with **no boxed futures and no
12//! heap**, while policies stay generic and zero-cost.
13
14use super::*;
15
16use core::cell::Cell;
17use embassy_sync::blocking_mutex::Mutex;
18use embassy_time::{Duration, Instant};
19
20/// Aggregate state of a pool, handed to the policy.
21#[derive(Clone, Copy)]
22pub struct PoolStats {
23    /// Instances currently up (spawned and not exited).
24    pub running: u8,
25    /// Of the running instances, how many are serving (marked busy).
26    pub busy: u8,
27    /// Floor — the pool never shrinks below this.
28    pub min: u8,
29    /// Ceiling — the pool never grows above this.
30    pub max: u8,
31}
32impl PoolStats {
33    /// Instances that are up but not serving (the spares).
34    pub fn idle(&self) -> u8 {
35        self.running.saturating_sub(self.busy)
36    }
37}
38
39/// What a policy wants done this evaluation.
40#[derive(Clone, Copy, PartialEq, Eq)]
41pub enum ScaleAction {
42    /// Leave the pool at its current size.
43    None,
44    /// Start one more instance (if below `max`).
45    Grow,
46    /// Stop one idle instance (if above `min`).
47    Shrink,
48}
49
50/// Swappable scaling decision. `decide` is synchronous; stateful policies use
51/// interior mutability (the pool is a `static`, so `&self`).
52pub trait ScalingPolicy {
53    /// Decide what to do given the current pool `stats` at time `now`.
54    fn decide(&self, stats: PoolStats, now: Instant) -> ScaleAction;
55
56    /// The next instant at which the pool must be re-evaluated even without a
57    /// status signal (e.g. a deferred shrink's cooldown). `None` = nothing
58    /// pending. The supervisor arms a one-shot timer for it.
59    fn deferred_until(&self) -> Option<Instant> {
60        None
61    }
62}
63
64/// Grow immediately (stay responsive), but shrink only after the idle surplus has
65/// persisted for `cooldown` — damps grow→shrink→grow flapping. Holds its pending
66/// shrink **deadline** in a `Cell<Option<Instant>>` (interior mutability under
67/// `&self`, since the pool is a `static`); `None` = no shrink pending.
68pub struct DeferredShrink {
69    cooldown: Duration,
70    /// Pending shrink deadline, or `None`. A critical-section mutex over a `Cell`
71    /// (not an `AtomicU64`) so it's Sync + const-constructible without pulling in
72    /// `portable_atomic`'s 64-bit lock-table fallback on Cortex-M.
73    pending: Mutex<CriticalSectionRawMutex, Cell<Option<Instant>>>,
74}
75impl DeferredShrink {
76    /// Create a policy that defers each shrink by `cooldown` after the pool first
77    /// becomes over-provisioned.
78    pub const fn new(cooldown: Duration) -> Self {
79        Self {
80            cooldown,
81            pending: Mutex::new(Cell::new(None)),
82        }
83    }
84}
85impl ScalingPolicy for DeferredShrink {
86    fn decide(&self, s: PoolStats, now: Instant) -> ScaleAction {
87        // Grow immediately, cancelling any pending shrink (we need this one).
88        if s.idle() == 0 && s.running < s.max {
89            self.pending.lock(|p| p.set(None));
90            return ScaleAction::Grow;
91        }
92        // Shrink only after the surplus has persisted the whole cooldown.
93        // idle == 1 is the stable dead-band (grow at 0, shrink at >= 2) so a
94        // single spare never flaps; at most one spare is released per cooldown.
95        if s.idle() >= 2 && s.running > s.min {
96            match self.pending.lock(|p| p.get()) {
97                None => {
98                    // First sight of surplus — arm the cooldown.
99                    self.pending.lock(|p| p.set(Some(now + self.cooldown)));
100                    ScaleAction::None
101                }
102                Some(deadline) if now >= deadline => {
103                    // Surplus held for the full cooldown — shrink one spare.
104                    // Re-arm only if a surplus will remain afterwards (idle-1 >=
105                    // 2), else clear (avoids a trailing no-op wake).
106                    let next = (s.idle() >= 3).then(|| now + self.cooldown);
107                    self.pending.lock(|p| p.set(next));
108                    ScaleAction::Shrink
109                }
110                Some(_) => ScaleAction::None, // still within the window
111            }
112        } else {
113            // No surplus (or at the floor) — cancel any pending shrink.
114            self.pending.lock(|p| p.set(None));
115            ScaleAction::None
116        }
117    }
118
119    fn deferred_until(&self) -> Option<Instant> {
120        self.pending.lock(|p| p.get())
121    }
122}
123
124/// What the supervisor should do for a pool this tick. The async part (start /
125/// stop) is applied by the caller, keeping `Pool` object-safe without futures.
126pub enum PoolAction {
127    /// Nothing to do this tick.
128    None,
129    /// Start this (currently down) pool member.
130    Start(&'static TaskNode),
131    /// Stop this (running, idle) pool member.
132    Stop(&'static TaskNode),
133}
134
135/// An elastic pool of single-instance nodes scaled by policy `P`.
136pub struct ElasticPool<P: ScalingPolicy> {
137    /// The pool's member nodes (each a single-instance `OnDemand`/`Terminate` node).
138    pub nodes: &'static [&'static TaskNode],
139    /// Floor — keep at least this many members running.
140    pub min: u8,
141    /// Ceiling — never run more than this many members.
142    pub max: u8,
143    /// The scaling policy driving grow/shrink decisions.
144    pub policy: P,
145}
146
147impl<P: ScalingPolicy> ElasticPool<P> {
148    fn stats(&self) -> PoolStats {
149        // One pass: count running nodes, and the busy subset of those.
150        let (running, busy) = self.nodes.iter().fold((0u8, 0u8), |(r, b), n| {
151            if n.is_running() {
152                (r + 1, b + n.is_busy() as u8)
153            } else {
154                (r, b)
155            }
156        });
157        PoolStats {
158            running,
159            busy,
160            min: self.min,
161            max: self.max,
162        }
163    }
164}
165
166/// Object-safe, **synchronous** pool interface so `&dyn Pool` needs no heap: the
167/// policy decides here; the supervisor performs the async start/stop.
168pub trait Pool: Sync {
169    /// Run the policy against the current snapshot and report the action to
170    /// apply. Does not itself start/stop (that's async — the caller does it).
171    fn evaluate(&self, now: Instant) -> PoolAction;
172    /// Earliest instant this pool must be re-evaluated without a signal.
173    fn deferred_until(&self) -> Option<Instant>;
174    /// The pool's member nodes (floor first). Used by the supervisor's control
175    /// interface to co-control a whole pool from any member, and by a task-state
176    /// view to group members. This is the single source of pool membership — the
177    /// same slice the scaling policy iterates.
178    fn members(&self) -> &'static [&'static TaskNode];
179}
180
181impl<P: ScalingPolicy + Sync> Pool for ElasticPool<P> {
182    fn evaluate(&self, now: Instant) -> PoolAction {
183        match self.policy.decide(self.stats(), now) {
184            // Grow a candidate that is OnDemand, down, and **not manually
185            // disabled** (the disabled check keeps a manually-stopped pool from
186            // being re-grown by the policy). Dependency-readiness is checked in
187            // `drive_pools` via `Supervisor::deps_running`.
188            ScaleAction::Grow => self
189                .nodes
190                .iter()
191                .find(|n| matches!(n.mode, Mode::OnDemand) && !n.is_running() && !n.is_disabled())
192                .map_or(PoolAction::None, |n| PoolAction::Start(n)),
193            ScaleAction::Shrink => self
194                .nodes
195                .iter()
196                .find(|n| matches!(n.mode, Mode::OnDemand) && n.is_running() && !n.is_busy())
197                .map_or(PoolAction::None, |n| PoolAction::Stop(n)),
198            ScaleAction::None => PoolAction::None,
199        }
200    }
201
202    fn deferred_until(&self) -> Option<Instant> {
203        self.policy.deferred_until()
204    }
205
206    fn members(&self) -> &'static [&'static TaskNode] {
207        self.nodes
208    }
209}
210
211/// Run every pool's policy and apply its chosen scaling action (evaluate is
212/// sync; the async start/stop happens here), returning the earliest deferred
213/// re-evaluation deadline across all pools, or `None`.
214async fn drive_pools<const N: usize>(
215    pools: &[&dyn Pool],
216    sup: &Supervisor<N>,
217    spawner: Spawner,
218) -> Option<Instant> {
219    let now = Instant::now();
220    let mut next: Option<Instant> = None;
221    for pool in pools {
222        match pool.evaluate(now) {
223            PoolAction::Start(n) => {
224                // Only grow when the candidate's dependencies are up. Spawn
225                // errors ignored: a lost start (e.g. SpawnError::Busy from
226                // embassy task-pool exhaustion — the policy itself enforces the
227                // pool ceiling) is simply re-driven on the next pass.
228                if sup.deps_running(n) {
229                    let _ = sup.start_node(n, spawner).await;
230                }
231            }
232            PoolAction::Stop(n) => sup.stop_node(n).await,
233            PoolAction::None => {}
234        }
235        if let Some(d) = pool.deferred_until() {
236            next = Some(next.map_or(d, |c| c.min(d)));
237        }
238    }
239    next
240}
241
242/// Future that fires at `deadline` if `Some`, else never — a pool's deferred
243/// re-evaluation wake (e.g. a shrink cooldown). Only armed while a deferral is
244/// outstanding, so an idle system never polls.
245async fn deadline_timer(deadline: Option<Instant>) {
246    match deadline {
247        Some(t) => Timer::at(t).await,
248        None => core::future::pending::<()>().await,
249    }
250}
251
252impl<const N: usize> Supervisor<N> {
253    /// Drive the registered elastic pools (from `GRAPH.pools`) forever: run their
254    /// policies, then park until the next status signal (`SCALE_REQ`) or a pool's
255    /// deferred deadline. Never completes — meant to be `select`ed against the
256    /// application's control / teardown futures in the supervisor task. When
257    /// another arm wins this future is dropped, which is safe: a half-applied
258    /// stop is re-driven on the next pass.
259    pub async fn run_pools(&self, spawner: Spawner) {
260        loop {
261            let next = drive_pools(self.pools, self, spawner).await;
262            select(wait_scale(), deadline_timer(next)).await;
263        }
264    }
265}
266
267// ─── Tests (host-only) ─────────────────────────────────────────────────────
268//
269// Pure scaling-policy logic: `decide` and `evaluate` take `now` as a parameter,
270// so no time driver is *called*; `Instant`s are built with `from_ticks` and offset
271// by `Duration`s. The policy's state cell is behind a `CriticalSectionRawMutex`, so
272// a critical-section impl is needed at link time — provided by the dev-dependency.
273// Run with `cargo test -p embassy-supervisor --target x86_64-unknown-linux-gnu`.
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::{Mode, TaskNode};
278    use embassy_executor::{SpawnError, Spawner};
279    use embassy_time::{Duration, Instant};
280
281    /// Spawn fn never invoked by these tests (no executor runs).
282    fn noop(_: Spawner) -> Result<(), SpawnError> {
283        Ok(())
284    }
285
286    /// A fixed base instant (tick 0); offset it with `Duration` arithmetic.
287    fn t0() -> Instant {
288        Instant::from_ticks(0)
289    }
290
291    fn stats(running: u8, busy: u8, min: u8, max: u8) -> PoolStats {
292        PoolStats {
293            running,
294            busy,
295            min,
296            max,
297        }
298    }
299
300    // ── DeferredShrink policy ──────────────────────────────────────────────
301
302    #[test]
303    fn grows_when_saturated_below_max() {
304        let p = DeferredShrink::new(Duration::from_secs(4));
305        // idle == 0 (all busy), running < max → grow immediately.
306        assert!(p.decide(stats(2, 2, 1, 4), t0()) == ScaleAction::Grow);
307    }
308
309    #[test]
310    fn does_not_grow_at_ceiling() {
311        let p = DeferredShrink::new(Duration::from_secs(4));
312        assert!(p.decide(stats(4, 4, 1, 4), t0()) == ScaleAction::None);
313    }
314
315    #[test]
316    fn defers_then_shrinks_after_cooldown() {
317        let cooldown = Duration::from_secs(4);
318        let p = DeferredShrink::new(cooldown);
319        let now = t0();
320
321        // Surplus (idle 2, running > min): first sight arms the cooldown, no action.
322        assert!(p.decide(stats(3, 1, 1, 4), now) == ScaleAction::None);
323        assert_eq!(p.deferred_until(), Some(now + cooldown));
324
325        // Still inside the window → hold.
326        assert!(p.decide(stats(3, 1, 1, 4), now + Duration::from_secs(2)) == ScaleAction::None);
327
328        // Cooldown elapsed → shrink one spare.
329        assert!(p.decide(stats(3, 1, 1, 4), now + cooldown) == ScaleAction::Shrink);
330    }
331
332    #[test]
333    fn cancels_pending_shrink_when_surplus_disappears() {
334        let cooldown = Duration::from_secs(4);
335        let p = DeferredShrink::new(cooldown);
336        let now = t0();
337
338        assert!(p.decide(stats(3, 1, 1, 4), now) == ScaleAction::None); // arm
339        assert!(p.deferred_until().is_some());
340
341        // idle drops to 1 (not saturated, not surplus) → pending cleared.
342        assert!(p.decide(stats(2, 1, 1, 4), now + Duration::from_secs(1)) == ScaleAction::None);
343        assert_eq!(p.deferred_until(), None);
344    }
345
346    #[test]
347    fn grow_clears_pending_shrink() {
348        let cooldown = Duration::from_secs(4);
349        let p = DeferredShrink::new(cooldown);
350        let now = t0();
351
352        assert!(p.decide(stats(3, 1, 1, 4), now) == ScaleAction::None); // arm
353        assert!(p.deferred_until().is_some());
354
355        // Becomes saturated → grow and cancel the pending shrink.
356        assert!(p.decide(stats(3, 3, 1, 4), now + Duration::from_secs(1)) == ScaleAction::Grow);
357        assert_eq!(p.deferred_until(), None);
358    }
359
360    // ── ElasticPool::evaluate ──────────────────────────────────────────────
361
362    #[test]
363    fn pool_grows_a_down_member_when_saturated() {
364        static N0: TaskNode = TaskNode::new("p0", Mode::Terminate, Some(noop), false);
365        static N1: TaskNode = TaskNode::new("p1", Mode::OnDemand, Some(noop), false);
366        static N2: TaskNode = TaskNode::new("p2", Mode::OnDemand, Some(noop), false);
367        static POOL: ElasticPool<DeferredShrink> = ElasticPool {
368            nodes: &[&N0, &N1, &N2],
369            min: 1,
370            max: 3,
371            policy: DeferredShrink::new(Duration::from_secs(4)),
372        };
373
374        // Only the floor is up and it's busy → saturated, below max → start a spare.
375        N0.set_running(true);
376        N0.mark_busy();
377        match POOL.evaluate(t0()) {
378            PoolAction::Start(n) => assert!(core::ptr::eq(n, &N1), "first down OnDemand member"),
379            _ => panic!("expected Start"),
380        }
381    }
382
383    #[test]
384    fn pool_shrinks_an_idle_member_after_cooldown() {
385        static N0: TaskNode = TaskNode::new("p0", Mode::Terminate, Some(noop), false);
386        static N1: TaskNode = TaskNode::new("p1", Mode::OnDemand, Some(noop), false);
387        static N2: TaskNode = TaskNode::new("p2", Mode::OnDemand, Some(noop), false);
388        static POOL: ElasticPool<DeferredShrink> = ElasticPool {
389            nodes: &[&N0, &N1, &N2],
390            min: 1,
391            max: 3,
392            policy: DeferredShrink::new(Duration::from_secs(4)),
393        };
394
395        // All three up, none busy → idle surplus.
396        N0.set_running(true);
397        N1.set_running(true);
398        N2.set_running(true);
399        let now = t0();
400
401        assert!(
402            matches!(POOL.evaluate(now), PoolAction::None),
403            "first tick arms cooldown"
404        );
405        match POOL.evaluate(now + Duration::from_secs(4)) {
406            PoolAction::Stop(n) => {
407                assert!(
408                    core::ptr::eq(n, &N1) || core::ptr::eq(n, &N2),
409                    "an idle OnDemand member"
410                );
411            }
412            _ => panic!("expected Stop"),
413        }
414    }
415
416    #[test]
417    fn pool_does_not_grow_a_disabled_member() {
418        static N0: TaskNode = TaskNode::new("p0", Mode::Terminate, Some(noop), false);
419        static N1: TaskNode = TaskNode::new("p1", Mode::OnDemand, Some(noop), false);
420        static POOL: ElasticPool<DeferredShrink> = ElasticPool {
421            nodes: &[&N0, &N1],
422            min: 1,
423            max: 2,
424            policy: DeferredShrink::new(Duration::from_secs(4)),
425        };
426
427        N0.set_running(true);
428        N0.mark_busy(); // saturated → policy wants to grow
429        N1.set_disabled(true); // but the only candidate is manually disabled
430        assert!(matches!(POOL.evaluate(t0()), PoolAction::None));
431    }
432}