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 /// This node's position in the pool's member array, or `None` if it isn't a
149 /// member. Lets a worker derive its member index from its `&'static
150 /// TaskNode` first argument and index per-member application state
151 /// (configs, buffers) without any per-member spawn arguments:
152 ///
153 /// ```ignore
154 /// let i = HTTP_POOL.member_index(node).unwrap();
155 /// let cfg = &MEMBER_CONFIG[i];
156 /// ```
157 pub fn member_index(&self, node: &'static TaskNode) -> Option<usize> {
158 self.nodes.iter().position(|m| core::ptr::eq(*m, node))
159 }
160
161 fn stats(&self) -> PoolStats {
162 // One pass: count running nodes, and the busy subset of those.
163 let (running, busy) = self.nodes.iter().fold((0u8, 0u8), |(r, b), n| {
164 if n.is_running() {
165 (r + 1, b + n.is_busy() as u8)
166 } else {
167 (r, b)
168 }
169 });
170 PoolStats {
171 running,
172 busy,
173 min: self.min,
174 max: self.max,
175 }
176 }
177}
178
179/// Object-safe, **synchronous** pool interface so `&dyn Pool` needs no heap: the
180/// policy decides here; the supervisor performs the async start/stop.
181pub trait Pool: Sync {
182 /// Run the policy against the current snapshot and report the action to
183 /// apply. Does not itself start/stop (that's async — the caller does it).
184 fn evaluate(&self, now: Instant) -> PoolAction;
185 /// Earliest instant this pool must be re-evaluated without a signal.
186 fn deferred_until(&self) -> Option<Instant>;
187 /// The pool's member nodes (floor first). Used by the supervisor's control
188 /// interface to co-control a whole pool from any member, and by a task-state
189 /// view to group members. This is the single source of pool membership — the
190 /// same slice the scaling policy iterates.
191 fn members(&self) -> &'static [&'static TaskNode];
192}
193
194impl<P: ScalingPolicy + Sync> Pool for ElasticPool<P> {
195 fn evaluate(&self, now: Instant) -> PoolAction {
196 match self.policy.decide(self.stats(), now) {
197 // Grow a candidate that is OnDemand, down, and **not manually
198 // disabled** (the disabled check keeps a manually-stopped pool from
199 // being re-grown by the policy). Dependency-readiness is checked in
200 // `drive_pools` via `Supervisor::deps_running`.
201 ScaleAction::Grow => self
202 .nodes
203 .iter()
204 .find(|n| matches!(n.mode, Mode::OnDemand) && !n.is_running() && !n.is_disabled())
205 .map_or(PoolAction::None, |n| PoolAction::Start(n)),
206 ScaleAction::Shrink => self
207 .nodes
208 .iter()
209 .find(|n| matches!(n.mode, Mode::OnDemand) && n.is_running() && !n.is_busy())
210 .map_or(PoolAction::None, |n| PoolAction::Stop(n)),
211 ScaleAction::None => PoolAction::None,
212 }
213 }
214
215 fn deferred_until(&self) -> Option<Instant> {
216 self.policy.deferred_until()
217 }
218
219 fn members(&self) -> &'static [&'static TaskNode] {
220 self.nodes
221 }
222}
223
224/// Run every pool's policy and apply its chosen scaling action (evaluate is
225/// sync; the async start/stop happens here), returning the earliest deferred
226/// re-evaluation deadline across all pools, or `None`. A shrink whose member
227/// misses its shutdown ack aborts the pass with [`ShutdownTimeout`].
228async fn drive_pools<const N: usize>(
229 pools: &[&dyn Pool],
230 sup: &Supervisor<N>,
231 spawner: Spawner,
232) -> Result<Option<Instant>, crate::ShutdownTimeout> {
233 let now = Instant::now();
234 let mut next: Option<Instant> = None;
235 for pool in pools {
236 match pool.evaluate(now) {
237 PoolAction::Start(n) => {
238 // Only grow when the candidate's dependencies are up — and,
239 // with `readiness`, when every ready-marked dep asserts ready
240 // (sync check: a not-ready dep just defers the grow to the
241 // next evaluation, no wait). Spawn errors ignored: a lost
242 // start (e.g. SpawnError::Busy from embassy task-pool
243 // exhaustion — the policy itself enforces the pool ceiling)
244 // is simply re-driven on the next pass.
245 if sup.deps_running(n) && n.ready_deps_ok() {
246 let _ = sup.start_node(n, spawner).await;
247 }
248 }
249 PoolAction::Stop(n) => sup.stop_node(n).await?,
250 PoolAction::None => {}
251 }
252 if let Some(d) = pool.deferred_until() {
253 next = Some(next.map_or(d, |c| c.min(d)));
254 }
255 }
256 Ok(next)
257}
258
259/// Future that fires at `deadline` if `Some`, else never — a pool's deferred
260/// re-evaluation wake (e.g. a shrink cooldown). Only armed while a deferral is
261/// outstanding, so an idle system never polls.
262async fn deadline_timer(deadline: Option<Instant>) {
263 match deadline {
264 Some(t) => Timer::at(t).await,
265 None => core::future::pending::<()>().await,
266 }
267}
268
269impl<const N: usize> Supervisor<N> {
270 /// Drive the registered elastic pools (from `GRAPH.pools`): run their
271 /// policies, then park until the next status signal (`SCALE_REQ`) or a pool's
272 /// deferred deadline. Runs forever in the success case — meant to be
273 /// `select`ed against the application's control / teardown futures in the
274 /// supervisor task; when another arm wins this future is dropped, which is
275 /// safe: a half-applied stop is re-driven on the next pass. **Returns only
276 /// on error**: a pool member that missed its shutdown ack during a shrink,
277 /// as [`ShutdownTimeout`] — the app escalates (its select arm typically
278 /// panics or triggers a watchdog reset).
279 pub async fn run_pools(&self, spawner: Spawner) -> crate::ShutdownTimeout {
280 loop {
281 let next = match drive_pools(self.pools, self, spawner).await {
282 Ok(next) => next,
283 Err(e) => return e,
284 };
285 select(wait_scale(), deadline_timer(next)).await;
286 }
287 }
288}