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 if s.idle() >= 2 && s.running > s.min {
94 match self.pending.lock(|p| p.get()) {
95 None => {
96 // First sight of surplus — arm the cooldown.
97 self.pending.lock(|p| p.set(Some(now + self.cooldown)));
98 ScaleAction::None
99 }
100 Some(deadline) if now >= deadline => {
101 // Surplus held for the full cooldown — shrink one spare.
102 // Re-arm only if a surplus will remain afterwards (idle-1 >=
103 // 2), else clear (avoids a trailing no-op wake).
104 let next = (s.idle() >= 3).then(|| now + self.cooldown);
105 self.pending.lock(|p| p.set(next));
106 ScaleAction::Shrink
107 }
108 Some(_) => ScaleAction::None, // still within the window
109 }
110 } else {
111 // No surplus (or at the floor) — cancel any pending shrink.
112 self.pending.lock(|p| p.set(None));
113 ScaleAction::None
114 }
115 }
116
117 fn deferred_until(&self) -> Option<Instant> {
118 self.pending.lock(|p| p.get())
119 }
120}
121
122/// What the supervisor should do for a pool this tick. The async part (start /
123/// stop) is applied by the caller, keeping `Pool` object-safe without futures.
124pub enum PoolAction {
125 /// Nothing to do this tick.
126 None,
127 /// Start this (currently down) pool member.
128 Start(&'static TaskNode),
129 /// Stop this (running, idle) pool member.
130 Stop(&'static TaskNode),
131}
132
133/// An elastic pool of single-instance nodes scaled by policy `P`.
134pub struct ElasticPool<P: ScalingPolicy> {
135 /// The pool's member nodes (each a single-instance `OnDemand`/`Terminate` node).
136 pub nodes: &'static [&'static TaskNode],
137 /// Floor — keep at least this many members running.
138 pub min: u8,
139 /// Ceiling — never run more than this many members.
140 pub max: u8,
141 /// The scaling policy driving grow/shrink decisions.
142 pub policy: P,
143}
144
145impl<P: ScalingPolicy> ElasticPool<P> {
146 fn stats(&self) -> PoolStats {
147 // One pass: count running nodes, and the busy subset of those.
148 let (running, busy) = self.nodes.iter().fold((0u8, 0u8), |(r, b), n| {
149 if n.is_running() {
150 (r + 1, b + n.is_busy() as u8)
151 } else {
152 (r, b)
153 }
154 });
155 PoolStats {
156 running,
157 busy,
158 min: self.min,
159 max: self.max,
160 }
161 }
162}
163
164/// Object-safe, **synchronous** pool interface so `&dyn Pool` needs no heap: the
165/// policy decides here; the supervisor performs the async start/stop.
166pub trait Pool: Sync {
167 /// Run the policy against the current snapshot and report the action to
168 /// apply. Does not itself start/stop (that's async — the caller does it).
169 fn evaluate(&self, now: Instant) -> PoolAction;
170 /// Earliest instant this pool must be re-evaluated without a signal.
171 fn deferred_until(&self) -> Option<Instant>;
172 /// The pool's member nodes (floor first). Used by the supervisor's control
173 /// interface to co-control a whole pool from any member, and by a task-state
174 /// view to group members. This is the single source of pool membership — the
175 /// same slice the scaling policy iterates.
176 fn members(&self) -> &'static [&'static TaskNode];
177}
178
179impl<P: ScalingPolicy + Sync> Pool for ElasticPool<P> {
180 fn evaluate(&self, now: Instant) -> PoolAction {
181 match self.policy.decide(self.stats(), now) {
182 // Grow a candidate that is OnDemand, down, and **not manually
183 // disabled** (the disabled check keeps a manually-stopped pool from
184 // being re-grown by the policy). Dependency-readiness is checked in
185 // `drive_pools` via `Supervisor::deps_running`.
186 ScaleAction::Grow => self
187 .nodes
188 .iter()
189 .find(|n| matches!(n.mode, Mode::OnDemand) && !n.is_running() && !n.is_disabled())
190 .map_or(PoolAction::None, |n| PoolAction::Start(n)),
191 ScaleAction::Shrink => self
192 .nodes
193 .iter()
194 .find(|n| matches!(n.mode, Mode::OnDemand) && n.is_running() && !n.is_busy())
195 .map_or(PoolAction::None, |n| PoolAction::Stop(n)),
196 ScaleAction::None => PoolAction::None,
197 }
198 }
199
200 fn deferred_until(&self) -> Option<Instant> {
201 self.policy.deferred_until()
202 }
203
204 fn members(&self) -> &'static [&'static TaskNode] {
205 self.nodes
206 }
207}
208
209/// Run every pool's policy and apply its chosen scaling action (evaluate is
210/// sync; the async start/stop happens here), returning the earliest deferred
211/// re-evaluation deadline across all pools, or `None`.
212async fn drive_pools<const N: usize>(
213 pools: &[&dyn Pool],
214 sup: &Supervisor<N>,
215 spawner: Spawner,
216) -> Option<Instant> {
217 let now = Instant::now();
218 let mut next: Option<Instant> = None;
219 for pool in pools {
220 match pool.evaluate(now) {
221 PoolAction::Start(n) => {
222 // Only grow when the candidate's dependencies are up.
223 // SpawnError::Busy at the pool ceiling → can't grow, no-op.
224 if sup.deps_running(n) {
225 let _ = sup.start_node(n, spawner);
226 }
227 }
228 PoolAction::Stop(n) => sup.stop_node(n).await,
229 PoolAction::None => {}
230 }
231 if let Some(d) = pool.deferred_until() {
232 next = Some(next.map_or(d, |c| c.min(d)));
233 }
234 }
235 next
236}
237
238/// Future that fires at `deadline` if `Some`, else never — a pool's deferred
239/// re-evaluation wake (e.g. a shrink cooldown). Only armed while a deferral is
240/// outstanding, so an idle system never polls.
241async fn deadline_timer(deadline: Option<Instant>) {
242 match deadline {
243 Some(t) => Timer::at(t).await,
244 None => core::future::pending::<()>().await,
245 }
246}
247
248impl<const N: usize> Supervisor<N> {
249 /// Drive the registered elastic pools (from `GRAPH.pools`) forever: run their
250 /// policies, then park until the next status signal (`SCALE_REQ`) or a pool's
251 /// deferred deadline. Never completes — meant to be `select`ed against the
252 /// application's control / teardown futures in the supervisor task. When
253 /// another arm wins this future is dropped, which is safe: a half-applied
254 /// stop is re-driven on the next pass.
255 pub async fn run_pools(&self, spawner: Spawner) {
256 loop {
257 let next = drive_pools(self.pools, self, spawner).await;
258 select(wait_scale(), deadline_timer(next)).await;
259 }
260 }
261}
262
263// ─── Tests (host-only) ─────────────────────────────────────────────────────
264//
265// Pure scaling-policy logic: `decide` and `evaluate` take `now` as a parameter,
266// so no time driver is *called*; `Instant`s are built with `from_ticks` and offset
267// by `Duration`s. The policy's state cell is behind a `CriticalSectionRawMutex`, so
268// a critical-section impl is needed at link time — provided by the dev-dependency.
269// Run with `cargo test -p embassy-supervisor --target x86_64-unknown-linux-gnu`.
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::{Mode, TaskNode};
274 use embassy_executor::{SpawnError, Spawner};
275 use embassy_time::{Duration, Instant};
276
277 /// Spawn fn never invoked by these tests (no executor runs).
278 fn noop(_: Spawner) -> Result<(), SpawnError> {
279 Ok(())
280 }
281
282 /// A fixed base instant (tick 0); offset it with `Duration` arithmetic.
283 fn t0() -> Instant {
284 Instant::from_ticks(0)
285 }
286
287 fn stats(running: u8, busy: u8, min: u8, max: u8) -> PoolStats {
288 PoolStats {
289 running,
290 busy,
291 min,
292 max,
293 }
294 }
295
296 // ── DeferredShrink policy ──────────────────────────────────────────────
297
298 #[test]
299 fn grows_when_saturated_below_max() {
300 let p = DeferredShrink::new(Duration::from_secs(4));
301 // idle == 0 (all busy), running < max → grow immediately.
302 assert!(p.decide(stats(2, 2, 1, 4), t0()) == ScaleAction::Grow);
303 }
304
305 #[test]
306 fn does_not_grow_at_ceiling() {
307 let p = DeferredShrink::new(Duration::from_secs(4));
308 assert!(p.decide(stats(4, 4, 1, 4), t0()) == ScaleAction::None);
309 }
310
311 #[test]
312 fn defers_then_shrinks_after_cooldown() {
313 let cooldown = Duration::from_secs(4);
314 let p = DeferredShrink::new(cooldown);
315 let now = t0();
316
317 // Surplus (idle 2, running > min): first sight arms the cooldown, no action.
318 assert!(p.decide(stats(3, 1, 1, 4), now) == ScaleAction::None);
319 assert_eq!(p.deferred_until(), Some(now + cooldown));
320
321 // Still inside the window → hold.
322 assert!(p.decide(stats(3, 1, 1, 4), now + Duration::from_secs(2)) == ScaleAction::None);
323
324 // Cooldown elapsed → shrink one spare.
325 assert!(p.decide(stats(3, 1, 1, 4), now + cooldown) == ScaleAction::Shrink);
326 }
327
328 #[test]
329 fn cancels_pending_shrink_when_surplus_disappears() {
330 let cooldown = Duration::from_secs(4);
331 let p = DeferredShrink::new(cooldown);
332 let now = t0();
333
334 assert!(p.decide(stats(3, 1, 1, 4), now) == ScaleAction::None); // arm
335 assert!(p.deferred_until().is_some());
336
337 // idle drops to 1 (not saturated, not surplus) → pending cleared.
338 assert!(p.decide(stats(2, 1, 1, 4), now + Duration::from_secs(1)) == ScaleAction::None);
339 assert_eq!(p.deferred_until(), None);
340 }
341
342 #[test]
343 fn grow_clears_pending_shrink() {
344 let cooldown = Duration::from_secs(4);
345 let p = DeferredShrink::new(cooldown);
346 let now = t0();
347
348 assert!(p.decide(stats(3, 1, 1, 4), now) == ScaleAction::None); // arm
349 assert!(p.deferred_until().is_some());
350
351 // Becomes saturated → grow and cancel the pending shrink.
352 assert!(p.decide(stats(3, 3, 1, 4), now + Duration::from_secs(1)) == ScaleAction::Grow);
353 assert_eq!(p.deferred_until(), None);
354 }
355
356 // ── ElasticPool::evaluate ──────────────────────────────────────────────
357
358 #[test]
359 fn pool_grows_a_down_member_when_saturated() {
360 static N0: TaskNode = TaskNode::new("p0", Mode::Terminate, Some(noop), false);
361 static N1: TaskNode = TaskNode::new("p1", Mode::OnDemand, Some(noop), false);
362 static N2: TaskNode = TaskNode::new("p2", Mode::OnDemand, Some(noop), false);
363 static POOL: ElasticPool<DeferredShrink> = ElasticPool {
364 nodes: &[&N0, &N1, &N2],
365 min: 1,
366 max: 3,
367 policy: DeferredShrink::new(Duration::from_secs(4)),
368 };
369
370 // Only the floor is up and it's busy → saturated, below max → start a spare.
371 N0.set_running(true);
372 N0.mark_busy();
373 match POOL.evaluate(t0()) {
374 PoolAction::Start(n) => assert!(core::ptr::eq(n, &N1), "first down OnDemand member"),
375 _ => panic!("expected Start"),
376 }
377 }
378
379 #[test]
380 fn pool_shrinks_an_idle_member_after_cooldown() {
381 static N0: TaskNode = TaskNode::new("p0", Mode::Terminate, Some(noop), false);
382 static N1: TaskNode = TaskNode::new("p1", Mode::OnDemand, Some(noop), false);
383 static N2: TaskNode = TaskNode::new("p2", Mode::OnDemand, Some(noop), false);
384 static POOL: ElasticPool<DeferredShrink> = ElasticPool {
385 nodes: &[&N0, &N1, &N2],
386 min: 1,
387 max: 3,
388 policy: DeferredShrink::new(Duration::from_secs(4)),
389 };
390
391 // All three up, none busy → idle surplus.
392 N0.set_running(true);
393 N1.set_running(true);
394 N2.set_running(true);
395 let now = t0();
396
397 assert!(
398 matches!(POOL.evaluate(now), PoolAction::None),
399 "first tick arms cooldown"
400 );
401 match POOL.evaluate(now + Duration::from_secs(4)) {
402 PoolAction::Stop(n) => {
403 assert!(
404 core::ptr::eq(n, &N1) || core::ptr::eq(n, &N2),
405 "an idle OnDemand member"
406 );
407 }
408 _ => panic!("expected Stop"),
409 }
410 }
411
412 #[test]
413 fn pool_does_not_grow_a_disabled_member() {
414 static N0: TaskNode = TaskNode::new("p0", Mode::Terminate, Some(noop), false);
415 static N1: TaskNode = TaskNode::new("p1", Mode::OnDemand, Some(noop), false);
416 static POOL: ElasticPool<DeferredShrink> = ElasticPool {
417 nodes: &[&N0, &N1],
418 min: 1,
419 max: 2,
420 policy: DeferredShrink::new(Duration::from_secs(4)),
421 };
422
423 N0.set_running(true);
424 N0.mark_busy(); // saturated → policy wants to grow
425 N1.set_disabled(true); // but the only candidate is manually disabled
426 assert!(matches!(POOL.evaluate(t0()), PoolAction::None));
427 }
428}