saluki_core/runtime/
builder.rs

1//! Builder for describing children.
2//!
3//! [`ChildSpecification`] is the complete description of a child, and this builder is the only way to configure one:
4//! the specification itself carries no public methods. The builder turns an asynchronous function, or a
5//! [`Supervisable`], into a child with sensible defaults, and uses the typestate pattern to expose only the settings
6//! that make sense for the kind of worker being described.
7//!
8//! It comes in matching ambient and explicit forms, mirroring the two ways to spawn: the free functions here
9//! ([`worker`], [`supervisable`]) target the ambient supervisor, and the identically named methods on
10//! [`SupervisorHandle`] target that handle's supervisor. Either way, [`ChildBuilder::spawn`] starts the child on a
11//! running supervisor, while [`ChildBuilder::build`] hands it to [`Supervisor::add_worker`][add_worker] to be started
12//! when that supervisor runs.
13//!
14//! [add_worker]: super::Supervisor::add_worker
15//!
16//! # Child lifecycle, and one-shot vs supervisable
17//!
18//! We classify tasks as either _one-shot_ or _supervisable_: one-shot tasks are those based on a provided closure,
19//! which cannot be reinitialized and so cannot be restarted, and supervisable tasks are those based on an
20//! implementation of [`Supervisable`], which allows for (potentially) initializing the underlying task future multiple
21//! times.
22//!
23//! One-shot tasks are always [`temporary`][RestartType::Temporary], since they cannot be reinitialized. Supervisable
24//! tasks are [`permanent`][RestartType::Permanent] by default, since their structure exposes a way to initialize the
25//! worker again; [`ChildBuilder::transient`] and [`ChildBuilder::temporary`] narrow that.
26//!
27//! All child tasks default to being marked as non-significant, so their termination -- clean or otherwise -- leaves
28//! the supervisor running. This is usually the correct behavior, but a supervisor that cannot function without a
29//! particular child may wish to mark it significant. See [`ChildBuilder::with_significant`], which is available only
30//! for a child whose restart policy lets it terminate for good; a permanent child is always brought back, so there is
31//! no termination for the supervisor to act on.
32//!
33//! # Shutdown
34//!
35//! Shutting a subtree down is a _trigger_, not an enforcement. A one-shot child is never handed the shutdown signal at
36//! all: it runs until it reaches its own terminal condition -- an input channel closing, a loop finishing, a request
37//! completing -- and the supervisor waits for it. That is what lets a set of tasks connected by channels drain in
38//! dependency order without any of them having to know that order, and it is why the shutdown signal being invisible
39//! here costs nothing.
40//!
41//! What bounds the wait is the supervisor's [shutdown budget][super::Supervisor::with_shutdown_budget], which covers
42//! the whole set of children rather than each guessing at how long it ought to take. A supervisor without a budget has
43//! nothing to bound such a child with, so it falls back to the worker's own strategy.
44//!
45//! Two cases need something other than the default:
46//!
47//! - Work with no terminal condition -- an endless background loop -- would hold the drain until the budget elapsed.
48//!   Give it [`ShutdownStrategy::Brutal`] via [`ChildBuilder::with_shutdown_strategy`] so it is aborted at once.
49//! - Work that must observe shutdown to know it should stop, or that needs to run cleanup, isn't a one-shot worker at
50//!   all: implement [`Supervisable`] directly, which does receive the signal.
51//!
52//! [`ChildBuilder::with_shutdown_timeout`] imposes a deadline shorter than the budget when a particular child should
53//! be abandoned sooner than its siblings.
54//!
55//! # Task naming
56//!
57//! Child task names should generally _not_ contain unique patterns/tokens -- such as monotonic IDs or high-cardinality
58//! values -- as they are used for internal telemetry about the task. Generally, task names should be thought of as a
59//! category label: if a supervisor manages tasks for handling connections, it should prefer to name them like
60//! `conn_handler` instead of `conn_handler_<ID or IP>`.
61
62use std::{future::Future, marker::PhantomData, time::Duration};
63
64use tokio::runtime::Handle;
65
66use super::{
67    ChildId, ChildSpecification, FnWorker, IntoWorkerResult, RestartType, ShutdownStrategy, Supervisable, Supervisor,
68    SupervisorHandle, SupervisorSpec, WorkerSpec,
69};
70
71/// Creates a builder for a child task on the ambient supervisor.
72///
73/// The ambient counterpart to [`SupervisorHandle::worker`].
74///
75/// # Panics
76///
77/// [`ChildBuilder::spawn`] panics if there is no ambient supervisor. See [`spawn`][super::spawn].
78pub fn worker<N, Fut>(name: N, fut: Fut) -> ChildBuilder<'static>
79where
80    N: Into<String>,
81    Fut: Future + Send + 'static,
82    Fut::Output: IntoWorkerResult,
83{
84    ChildBuilder::one_shot(BuilderTarget::Ambient, FnWorker::new(name, fut))
85}
86
87/// Creates a builder for a supervisable child task on the ambient supervisor.
88///
89/// The ambient counterpart to [`SupervisorHandle::supervisable`], which documents what a supervisable task is.
90///
91/// # Panics
92///
93/// [`ChildBuilder::spawn`] panics if there is no ambient supervisor. See [`spawn`][super::spawn].
94pub fn supervisable<T>(worker: T) -> ChildBuilder<'static, Restartable>
95where
96    T: Supervisable + 'static,
97{
98    ChildBuilder::restartable(BuilderTarget::Ambient, worker)
99}
100
101impl SupervisorHandle {
102    /// Creates a builder for a child task built from a plain future.
103    ///
104    /// The task runs until it reaches its own terminal condition; it is never handed the shutdown signal. See
105    /// [`FnWorker`] for what that means at shutdown, and for the two cases that need something else.
106    ///
107    /// Use this method when advanced configuration of the underlying task is required. Otherwise, prefer
108    /// [`spawn_worker`][Self::spawn_worker].
109    ///
110    /// # Examples
111    ///
112    /// ```no_run
113    /// # use saluki_core::runtime::SupervisorHandle;
114    /// # async fn encode() {}
115    /// # fn example(supervisor: &SupervisorHandle, pool: tokio::runtime::Handle) {
116    /// supervisor.worker("encoder", encode()).on_runtime(pool).spawn();
117    /// # }
118    /// ```
119    pub fn worker<N, Fut>(&self, name: N, fut: Fut) -> ChildBuilder<'_>
120    where
121        N: Into<String>,
122        Fut: Future + Send + 'static,
123        Fut::Output: IntoWorkerResult,
124    {
125        ChildBuilder::one_shot(BuilderTarget::Handle(self), FnWorker::new(name, fut))
126    }
127
128    /// Creates a builder for a supervisable child task.
129    ///
130    /// Supervisable tasks are those where the worker already implements [`Supervisable`], which lets the builder serve
131    /// as a consistent control surface for spawning both arbitrary asynchronous functions and more full-fledged
132    /// workers.
133    ///
134    /// Supervisable tasks are set to permanently restart by default.
135    ///
136    /// Use this method when advanced configuration of the underlying task is required. Otherwise, prefer
137    /// [`spawn_supervisable`][Self::spawn_supervisable].
138    pub fn supervisable<T>(&self, worker: T) -> ChildBuilder<'_, Restartable>
139    where
140        T: Supervisable + 'static,
141    {
142        ChildBuilder::restartable(BuilderTarget::Handle(self), worker)
143    }
144
145    /// Spawns a child task built from a plain future.
146    ///
147    /// The task runs until it reaches its own terminal condition; it is never handed the shutdown signal. See
148    /// [`FnWorker`] for what that means at shutdown, and for the two cases that need something else.
149    ///
150    /// Use [`worker`][Self::worker] when advanced configuration of the underlying task is required.
151    pub fn spawn_worker<N, Fut>(&self, name: N, fut: Fut) -> ChildId
152    where
153        N: Into<String>,
154        Fut: Future + Send + 'static,
155        Fut::Output: IntoWorkerResult,
156    {
157        self.worker(name, fut).spawn()
158    }
159
160    /// Spawns a supervisable child task.
161    ///
162    /// Supervisable tasks are those where the worker already implements [`Supervisable`], which lets the builder serve
163    /// as a consistent control surface for spawning both arbitrary asynchronous functions and more full-fledged
164    /// workers.
165    ///
166    /// Use [`supervisable`][Self::supervisable] when advanced configuration of the underlying task is required.
167    pub fn spawn_supervisable<T>(&self, worker: T) -> ChildId
168    where
169        T: Supervisable + 'static,
170    {
171        self.supervisable(worker).spawn()
172    }
173
174    /// Creates a builder for a nested supervisor.
175    ///
176    /// A [`Supervisor`] can be handed to [`spawn`][Self::spawn] or [`Supervisor::add_worker`] directly, which is all
177    /// most callers need. This builder exists for the settings that aren't reachable that way: the restart policy and
178    /// significance. It matters most for a dynamically spawned subtree, which would otherwise be
179    /// [`temporary`][RestartType::Temporary] and so quietly stay dead once it terminated.
180    ///
181    /// Unlike [`supervisable`][Self::supervisable], there is no placement or shutdown setting. A nested supervisor
182    /// runs wherever its parent does and its children carry their own placement, and it bounds its own drain through
183    /// those children rather than through a deadline imposed from above.
184    ///
185    /// Nested supervisors are set to permanently restart by default.
186    pub fn nested_supervisor(&self, supervisor: Supervisor) -> NestedSupervisorBuilder<'_> {
187        NestedSupervisorBuilder::new(BuilderTarget::Handle(self), supervisor)
188    }
189}
190
191/// Creates a builder for a nested supervisor on the ambient supervisor.
192///
193/// The ambient counterpart to [`SupervisorHandle::nested_supervisor`], which documents what the builder is for.
194///
195/// # Panics
196///
197/// [`NestedSupervisorBuilder::spawn`] panics if there is no ambient supervisor. See [`spawn`][super::spawn].
198pub fn nested_supervisor(supervisor: Supervisor) -> NestedSupervisorBuilder<'static> {
199    NestedSupervisorBuilder::new(BuilderTarget::Ambient, supervisor)
200}
201
202mod sealed {
203    pub trait Sealed {}
204}
205
206/// The kind of worker a [`ChildBuilder`] is describing.
207///
208/// This trait is sealed, and exists only to mark which configuration a builder makes available: a worker that can be
209/// initialized more than once accepts a restart policy, and one that can't doesn't. Implemented by [`OneShot`],
210/// [`Restartable`] and [`Terminable`].
211pub trait BuilderState: sealed::Sealed {}
212
213/// Marks a builder whose child can terminate without being restarted.
214///
215/// This trait is sealed, and exists only to gate [`ChildBuilder::with_significant`]: significance asks what the
216/// supervisor should do when a child terminates and isn't brought back, so it means nothing for a child that always
217/// is. Implemented by [`OneShot`] and [`Terminable`].
218pub trait CanTerminate: BuilderState {}
219
220/// Marks a builder whose worker can only be initialized once.
221///
222/// Closure-based children ([`SupervisorHandle::worker`]) consume their body when they start, so they can never be
223/// restarted: they are always [`RestartType::Temporary`], and no restart policy is offered.
224pub struct OneShot;
225
226/// Marks a builder whose worker can be initialized more than once.
227///
228/// A [`Supervisable`] builds its work in [`initialize`][Supervisable::initialize] each time it starts, so it can be
229/// restarted, and it is [`RestartType::Permanent`] unless narrowed with [`ChildBuilder::transient`] or
230/// [`ChildBuilder::temporary`].
231pub struct Restartable;
232
233/// Marks a builder whose worker can be initialized more than once but has been narrowed to a restart policy that lets
234/// it terminate for good.
235///
236/// Reached from [`Restartable`] via [`ChildBuilder::transient`] or [`ChildBuilder::temporary`]. Narrowing is one-way:
237/// there is no route back to [`Restartable`], which is what keeps a child from being marked significant and then
238/// widened to [`RestartType::Permanent`] behind the flag's back.
239pub struct Terminable;
240
241impl sealed::Sealed for OneShot {}
242impl BuilderState for OneShot {}
243impl CanTerminate for OneShot {}
244impl sealed::Sealed for Restartable {}
245impl BuilderState for Restartable {}
246impl sealed::Sealed for Terminable {}
247impl BuilderState for Terminable {}
248impl CanTerminate for Terminable {}
249
250/// Builder for a yet-to-be-started child task.
251///
252/// This is the only way to configure a child: [`ChildSpecification`], which this produces, carries no settings of its
253/// own. The builder uses the typestate pattern to expose only the properties that make sense for the child being
254/// described, so an invalid combination doesn't need rejecting at runtime because it can't be written down. Two axes
255/// govern that: whether the worker can be initialized more than once (which decides whether a restart policy is
256/// offered), and whether its policy lets it terminate for good (which decides whether it can be marked significant).
257///
258/// See [`BuilderState`] and [`CanTerminate`] for the states themselves.
259#[must_use = "a child is only described until `spawn` or `build` is called"]
260pub struct ChildBuilder<'a, S = OneShot> {
261    target: BuilderTarget<'a>,
262    spec: ChildSpecification<WorkerSpec>,
263    _state: PhantomData<S>,
264}
265
266/// Which supervisor a [`ChildBuilder`] spawns onto.
267#[derive(Clone, Copy)]
268enum BuilderTarget<'a> {
269    /// Whichever supervisor is ambient when the child is spawned.
270    Ambient,
271
272    /// This specific supervisor.
273    Handle(&'a SupervisorHandle),
274}
275
276impl<'a, S: BuilderState> ChildBuilder<'a, S> {
277    fn new(target: BuilderTarget<'a>, spec: ChildSpecification<WorkerSpec>) -> Self {
278        Self {
279            target,
280            spec,
281            _state: PhantomData,
282        }
283    }
284
285    fn map_spec<F>(self, f: F) -> Self
286    where
287        F: FnOnce(ChildSpecification<WorkerSpec>) -> ChildSpecification<WorkerSpec>,
288    {
289        self.map_spec_into(f)
290    }
291
292    /// As [`map_spec`][Self::map_spec], but for a transition that also moves the builder to a different state.
293    fn map_spec_into<F, S2>(self, f: F) -> ChildBuilder<'a, S2>
294    where
295        F: FnOnce(ChildSpecification<WorkerSpec>) -> ChildSpecification<WorkerSpec>,
296        S2: BuilderState,
297    {
298        let Self { target, spec, .. } = self;
299
300        ChildBuilder::new(target, f(spec))
301    }
302
303    /// Runs this child task on a specific runtime.
304    ///
305    /// Use this for compute-heavy work -- encoding, serialization, protocol servers -- that shouldn't contend with the
306    /// runtime driving the supervisor and its I/O. Only where the task runs changes: the child is still supervised
307    /// here, and is still shut down and restarted by this supervisor.
308    pub fn on_runtime(self, handle: Handle) -> Self {
309        self.map_spec(|spec| spec.with_runtime(handle))
310    }
311
312    /// Sets an explicit shutdown timeout for this child task.
313    ///
314    /// By default a closure-based child has no deadline of its own and is bounded only by the supervisor's shutdown
315    /// budget. Set this when the supervisor wants a particular child abandoned sooner than that -- a deadline it is
316    /// deliberately imposing, rather than a guess at how long the child ought to take. A value longer than the budget
317    /// has no effect, since the two are resolved to whichever elapses first.
318    ///
319    /// For a [`Supervisable`] child, this overrides the strategy the task reports for itself.
320    pub fn with_shutdown_timeout(self, timeout: Duration) -> Self {
321        self.with_shutdown_strategy(ShutdownStrategy::Graceful(timeout))
322    }
323
324    /// Sets the explicit shutdown strategy used for this child task.
325    pub fn with_shutdown_strategy(self, strategy: ShutdownStrategy) -> Self {
326        self.map_spec(|spec| spec.with_shutdown_strategy(strategy))
327    }
328
329    /// Gives this child no shutdown deadline of its own, leaving it bounded solely by the supervisor's shutdown
330    /// budget.
331    ///
332    /// This is already the default for a closure-based child. A [`Supervisable`] child reports its own strategy, so
333    /// this is how one opts into being bounded as part of the group instead -- without which it silently keeps
334    /// whatever [`Supervisable::shutdown_strategy`] returns, which may be far shorter than the budget.
335    ///
336    /// See [`Supervisor::with_shutdown_budget`][budget] for what the budget itself is.
337    ///
338    /// [budget]: super::Supervisor::with_shutdown_budget
339    pub fn with_budget_bounded_shutdown(self) -> Self {
340        self.map_spec(|spec| spec.with_budget_bounded_shutdown())
341    }
342
343    /// Finishes describing the child without starting it, for [`Supervisor::add_worker`].
344    ///
345    /// Use this to register a configured child on a supervisor that hasn't started yet; a child described this way is
346    /// started when the supervisor runs, and restarted with it. [`spawn`][Self::spawn] is the counterpart for a
347    /// supervisor that is already running.
348    ///
349    /// Whichever supervisor this builder was created against is irrelevant here -- the child belongs to whichever one
350    /// it is handed to.
351    ///
352    /// [`Supervisor::add_worker`]: super::Supervisor::add_worker
353    pub fn build(self) -> ChildSpecification<WorkerSpec> {
354        self.spec
355    }
356
357    /// Spawns the child.
358    ///
359    /// Returns the child's [`ChildId`]. The child is queued for the supervisor rather than started synchronously, so
360    /// it may not be running yet by the time this returns; if the supervisor isn't running, or shuts down before
361    /// reaching the child, it never runs at all.
362    ///
363    /// # Panics
364    ///
365    /// If this builder targets the ambient supervisor and there isn't one, this panics. See [`spawn`][super::spawn].
366    pub fn spawn(self) -> ChildId {
367        let Self { target, spec, .. } = self;
368
369        match target {
370            BuilderTarget::Ambient => super::spawn(spec),
371            BuilderTarget::Handle(supervisor) => supervisor.spawn(spec),
372        }
373    }
374}
375
376impl<'a, S: CanTerminate> ChildBuilder<'a, S> {
377    /// Sets whether this child task's termination should stop the supervisor.
378    ///
379    /// Under [`AutoShutdown::AnySignificant`][auto_shutdown] -- which is what a topology component's supervisor uses
380    /// -- a significant child terminating **shuts the supervisor down**: the child is not individually restarted. That
381    /// happens even when the child exits cleanly, so this suits a child the supervisor cannot function without, and
382    /// not one that is expected to finish on its own.
383    ///
384    /// For example, a component handling client connections generally shouldn't stop just because one connection
385    /// failed, but a component forwarding work to a child task may become inoperable if that task dies and cannot be
386    /// reattached to the necessary channels or state without recreating the component.
387    ///
388    /// Offered only for a child that can actually terminate for good -- a one-shot child, or a supervisable one
389    /// narrowed with [`transient`][ChildBuilder::transient] or [`temporary`][ChildBuilder::temporary]. A permanent
390    /// child is always restarted and so never reaches this path, which makes marking one significant a contradiction
391    /// rather than a setting.
392    ///
393    /// Defaults to `false`.
394    ///
395    /// [auto_shutdown]: super::AutoShutdown::AnySignificant
396    pub fn with_significant(self, significant: bool) -> Self {
397        self.map_spec(|spec| spec.with_significant(significant))
398    }
399}
400
401impl<'a> ChildBuilder<'a, OneShot> {
402    fn one_shot<T>(target: BuilderTarget<'a>, worker: T) -> Self
403    where
404        T: Supervisable + 'static,
405    {
406        let spec = ChildSpecification::one_shot_worker(worker).with_budget_bounded_shutdown();
407
408        Self::new(target, spec)
409    }
410}
411
412impl<'a> ChildBuilder<'a, Restartable> {
413    fn restartable<T>(target: BuilderTarget<'a>, worker: T) -> Self
414    where
415        T: Supervisable + 'static,
416    {
417        Self::new(
418            target,
419            ChildSpecification::worker(worker).with_restart_type(RestartType::Permanent),
420        )
421    }
422
423    /// Restarts this child task only when it terminates abnormally.
424    ///
425    /// A clean exit is taken at face value and the child stays stopped; a failure is restarted. Use this for work that
426    /// has a natural end but whose failure means it never got there.
427    ///
428    /// Narrows the restart policy to [`RestartType::Transient`], which makes
429    /// [`with_significant`][ChildBuilder::with_significant] available: a child that can stop for good is one whose
430    /// termination the supervisor may want to act on.
431    pub fn transient(self) -> ChildBuilder<'a, Terminable> {
432        self.map_spec_into(|spec| spec.with_restart_type(RestartType::Transient))
433    }
434
435    /// Never restarts this child task.
436    ///
437    /// However it terminates -- cleanly or by failing -- the child stays stopped. Use this for work that is meant to
438    /// run once, where a retry would be wrong rather than merely redundant.
439    ///
440    /// Narrows the restart policy to [`RestartType::Temporary`], which makes
441    /// [`with_significant`][ChildBuilder::with_significant] available: a child that can stop for good is one whose
442    /// termination the supervisor may want to act on.
443    pub fn temporary(self) -> ChildBuilder<'a, Terminable> {
444        self.map_spec_into(|spec| spec.with_restart_type(RestartType::Temporary))
445    }
446}
447
448/// Builder for a yet-to-be-started nested supervisor.
449///
450/// The counterpart to [`ChildBuilder`] for a child that is itself a [`Supervisor`], and deliberately a much smaller
451/// surface. A nested supervisor has no placement of its own (it runs wherever its parent does, and its children carry
452/// their own placement) and no shutdown deadline of its own (it bounds its own drain through its children), so what
453/// is left to configure is the restart policy and significance.
454///
455/// Uses the same typestate as [`ChildBuilder`]: significance is offered only once the restart policy has been
456/// narrowed to one that lets the child terminate for good, since a permanent child is always brought back and so
457/// never has a termination for the parent to act on.
458#[must_use = "a child is only described until `spawn` or `build` is called"]
459pub struct NestedSupervisorBuilder<'a, S = Restartable> {
460    target: BuilderTarget<'a>,
461    spec: ChildSpecification<SupervisorSpec>,
462    _state: PhantomData<S>,
463}
464
465impl<'a, S: BuilderState> NestedSupervisorBuilder<'a, S> {
466    fn from_parts(target: BuilderTarget<'a>, spec: ChildSpecification<SupervisorSpec>) -> Self {
467        Self {
468            target,
469            spec,
470            _state: PhantomData,
471        }
472    }
473
474    /// As [`map_spec`][ChildBuilder::map_spec], but for a transition that also moves the builder to a different state.
475    fn map_spec_into<F, S2>(self, f: F) -> NestedSupervisorBuilder<'a, S2>
476    where
477        F: FnOnce(ChildSpecification<SupervisorSpec>) -> ChildSpecification<SupervisorSpec>,
478        S2: BuilderState,
479    {
480        let Self { target, spec, .. } = self;
481
482        NestedSupervisorBuilder::from_parts(target, f(spec))
483    }
484
485    /// Finishes describing the child without starting it, for [`Supervisor::add_worker`].
486    ///
487    /// Use this to register a configured subtree on a supervisor that hasn't started yet; [`spawn`][Self::spawn] is
488    /// the counterpart for a supervisor that is already running.
489    ///
490    /// Whichever supervisor this builder was created against is irrelevant here -- the child belongs to whichever one
491    /// it is handed to.
492    pub fn build(self) -> ChildSpecification<SupervisorSpec> {
493        self.spec
494    }
495
496    /// Spawns the nested supervisor.
497    ///
498    /// Returns the child's [`ChildId`]. As with [`ChildBuilder::spawn`], the child is queued rather than started
499    /// synchronously.
500    ///
501    /// # Panics
502    ///
503    /// If this builder targets the ambient supervisor and there isn't one, this panics. See [`spawn`][super::spawn].
504    pub fn spawn(self) -> ChildId {
505        let Self { target, spec, .. } = self;
506
507        match target {
508            BuilderTarget::Ambient => super::spawn(spec),
509            BuilderTarget::Handle(supervisor) => supervisor.spawn(spec),
510        }
511    }
512}
513
514impl<'a> NestedSupervisorBuilder<'a, Restartable> {
515    fn new(target: BuilderTarget<'a>, supervisor: Supervisor) -> Self {
516        Self::from_parts(
517            target,
518            ChildSpecification::from(supervisor).with_restart_type(RestartType::Permanent),
519        )
520    }
521
522    /// Restarts this subtree only when it terminates abnormally.
523    ///
524    /// Narrows the restart policy to [`RestartType::Transient`], which makes
525    /// [`with_significant`][NestedSupervisorBuilder::with_significant] available.
526    pub fn transient(self) -> NestedSupervisorBuilder<'a, Terminable> {
527        self.map_spec_into(|spec| spec.with_restart_type(RestartType::Transient))
528    }
529
530    /// Never restarts this subtree.
531    ///
532    /// Narrows the restart policy to [`RestartType::Temporary`], which makes
533    /// [`with_significant`][NestedSupervisorBuilder::with_significant] available.
534    pub fn temporary(self) -> NestedSupervisorBuilder<'a, Terminable> {
535        self.map_spec_into(|spec| spec.with_restart_type(RestartType::Temporary))
536    }
537}
538
539impl<S: CanTerminate> NestedSupervisorBuilder<'_, S> {
540    /// Sets whether this subtree's termination should stop the parent supervisor.
541    ///
542    /// See [`ChildBuilder::with_significant`], which this mirrors.
543    pub fn with_significant(self, significant: bool) -> Self {
544        self.map_spec_into(|spec| spec.with_significant(significant))
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use std::sync::{
551        atomic::{AtomicUsize, Ordering},
552        Arc, Mutex,
553    };
554    use std::thread::ThreadId;
555
556    use async_trait::async_trait;
557    use saluki_common::sync::shutdown::ShutdownHandle;
558    use saluki_metrics::test::TestRecorder;
559    use tokio::sync::oneshot;
560    use tokio::time::timeout;
561
562    use super::*;
563    use crate::components::test_util::TestComponentSupervisor;
564    use crate::runtime::{InitializationError, SupervisorError, SupervisorFuture};
565    use crate::test_support::wait_until;
566
567    /// How long the drain-participating child in these tests takes to finish after its input closes.
568    ///
569    /// Long enough that a child given any short deadline of its own would be aborted before completing, so the tests
570    /// fail if children stop being bounded by the supervisor's budget alone.
571    const DRAIN_DURATION: Duration = Duration::from_millis(300);
572
573    /// A hand-written [`Supervisable`] that records where and how often it was initialized, then runs until shutdown.
574    struct CountingWorker {
575        name: &'static str,
576        initializations: Arc<AtomicUsize>,
577        thread_id: Arc<Mutex<Option<ThreadId>>>,
578    }
579
580    impl CountingWorker {
581        fn new(name: &'static str) -> (Self, Arc<AtomicUsize>, Arc<Mutex<Option<ThreadId>>>) {
582            let thread_id = Arc::new(Mutex::new(None));
583            let initializations = Arc::new(AtomicUsize::new(0));
584
585            (
586                Self {
587                    name,
588                    initializations: Arc::clone(&initializations),
589                    thread_id: Arc::clone(&thread_id),
590                },
591                initializations,
592                thread_id,
593            )
594        }
595    }
596
597    #[async_trait]
598    impl Supervisable for CountingWorker {
599        fn name(&self) -> &str {
600            self.name
601        }
602
603        fn shutdown_strategy(&self) -> ShutdownStrategy {
604            ShutdownStrategy::Graceful(Duration::MAX)
605        }
606
607        async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
608            self.initializations.fetch_add(1, Ordering::SeqCst);
609            *self.thread_id.lock().unwrap() = Some(std::thread::current().id());
610
611            Ok(Box::pin(async move {
612                process_shutdown.await;
613                Ok(())
614            }))
615        }
616    }
617
618    /// A [`Supervisable`] that fails its first run and waits for shutdown on every run after.
619    struct FailingOnceWorker {
620        initializations: Arc<AtomicUsize>,
621    }
622
623    impl FailingOnceWorker {
624        fn new() -> (Self, Arc<AtomicUsize>) {
625            let initializations = Arc::new(AtomicUsize::new(0));
626            (
627                Self {
628                    initializations: Arc::clone(&initializations),
629                },
630                initializations,
631            )
632        }
633    }
634
635    #[async_trait]
636    impl Supervisable for FailingOnceWorker {
637        fn name(&self) -> &str {
638            "failing_once"
639        }
640
641        fn shutdown_strategy(&self) -> ShutdownStrategy {
642            ShutdownStrategy::Graceful(Duration::MAX)
643        }
644
645        async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
646            let first_run = self.initializations.fetch_add(1, Ordering::SeqCst) == 0;
647
648            Ok(Box::pin(async move {
649                if first_run {
650                    return Err(saluki_error::generic_error!("first run always fails"));
651                }
652
653                process_shutdown.await;
654                Ok(())
655            }))
656        }
657    }
658
659    #[tokio::test]
660    async fn worker_drains_to_its_terminal_condition_before_the_supervisor_stops() {
661        // The central promise of a one-shot worker: shutdown is a trigger, not an enforcement, so a worker that is
662        // still finishing when the drain starts gets to finish. A clean supervisor result is the assertion that
663        // matters -- `ShutdownTimedOut` would mean it was aborted instead.
664        let supervisor = TestComponentSupervisor::start("test_component").await;
665
666        // The child does real work *after* its input closes, so it would be cut short if children were given a
667        // near-zero deadline rather than being bounded by the supervisor's budget.
668        let drained = Arc::new(AtomicUsize::new(0));
669        let child_drained = Arc::clone(&drained);
670        let (input_tx, input_rx) = oneshot::channel::<()>();
671
672        supervisor.handle().spawn_worker("drainer", async move {
673            let _ = input_rx.await;
674            tokio::time::sleep(DRAIN_DURATION).await;
675            child_drained.fetch_add(1, Ordering::SeqCst);
676        });
677
678        supervisor.wait_for_children(1).await;
679
680        // Close the child's input, then immediately shut down while it is still draining.
681        drop(input_tx);
682        let result = supervisor.shutdown().await;
683        assert!(
684            result.is_ok(),
685            "child should have drained rather than been aborted: {result:?}"
686        );
687        assert_eq!(drained.load(Ordering::SeqCst), 1);
688    }
689
690    #[tokio::test]
691    async fn worker_without_a_terminal_condition_is_aborted_when_marked_brutal() {
692        // The counterpart: work that never ends has to say so, otherwise it holds the drain open until the budget
693        // elapses. `Brutal` is how it says so, and the supervisor still reports a clean shutdown.
694        let supervisor = TestComponentSupervisor::start_with_budget("test_component", Duration::from_secs(30)).await;
695
696        supervisor
697            .handle()
698            .worker("endless", std::future::pending::<()>())
699            .with_shutdown_strategy(ShutdownStrategy::Brutal)
700            .spawn();
701        supervisor.wait_for_children(1).await;
702
703        let started = tokio::time::Instant::now();
704        let result = supervisor.shutdown().await;
705        let elapsed = started.elapsed();
706
707        assert!(
708            result.is_ok(),
709            "an aborted brutal child is not an unclean shutdown: {result:?}"
710        );
711        assert!(
712            elapsed < Duration::from_secs(5),
713            "the child should have been aborted at once rather than held to the 30s budget; took {elapsed:?}"
714        );
715    }
716
717    #[tokio::test]
718    async fn endless_worker_is_bounded_by_the_supervisors_budget() {
719        // A one-shot child carries no deadline of its own, so the supervisor's budget is what bounds it and a stuck
720        // child is aborted when the budget elapses.
721        //
722        // This deliberately does not try to distinguish that from a child having silently fallen back to the
723        // `Supervisable` trait default of five seconds: deadlines resolve to whichever elapses first, so any budget
724        // under five seconds produces an identical result.
725        let supervisor = TestComponentSupervisor::start_with_budget("test_component", Duration::from_millis(200)).await;
726
727        supervisor.handle().spawn_worker("stuck", std::future::pending::<()>());
728        supervisor.wait_for_children(1).await;
729
730        let started = tokio::time::Instant::now();
731        let result = supervisor.shutdown().await;
732        let elapsed = started.elapsed();
733
734        assert!(
735            matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
736            "the budget should have aborted the stuck child, got {result:?}"
737        );
738        assert!(
739            elapsed < Duration::from_secs(1),
740            "the child should have been bounded by the 200ms budget rather than a deadline of its own; took {elapsed:?}"
741        );
742    }
743
744    #[tokio::test]
745    async fn on_runtime_places_the_child_on_that_runtime() {
746        // `on_runtime` must actually change where the child's task runs, not just where it was spawned from.
747        let pool = tokio::runtime::Builder::new_multi_thread()
748            .worker_threads(1)
749            .thread_name("builder-pool-test")
750            .enable_all()
751            .build()
752            .expect("should build pool");
753
754        let supervisor = TestComponentSupervisor::start("test_component").await;
755
756        let (thread_tx, thread_rx) = oneshot::channel();
757        supervisor
758            .handle()
759            .worker("pooled", async move {
760                let _ = thread_tx.send(std::thread::current().name().unwrap_or_default().to_string());
761                std::future::pending::<()>().await;
762            })
763            .on_runtime(pool.handle().clone())
764            .with_shutdown_strategy(ShutdownStrategy::Brutal)
765            .spawn();
766
767        let thread_name = timeout(Duration::from_secs(5), thread_rx)
768            .await
769            .expect("child should report its thread promptly")
770            .expect("child should not be dropped before reporting");
771        assert!(
772            thread_name.starts_with("builder-pool-test"),
773            "child must run on the given runtime, but ran on thread {thread_name:?}"
774        );
775
776        assert!(supervisor.shutdown().await.is_ok());
777        pool.shutdown_background();
778    }
779
780    #[tokio::test]
781    async fn child_exiting_does_not_shut_the_supervisor_down() {
782        // Children are non-significant, so one finishing -- the normal case during a drain -- must not trip the
783        // supervisor's `AutoShutdown::AnySignificant` policy and tear the component down with it.
784        let supervisor = TestComponentSupervisor::start("test_component").await;
785        let handle = supervisor.handle();
786
787        // Wait for the child to have actually run and exited. `wait_for_children(0)` would be vacuously true before
788        // the supervisor ever picked it up, so the exit this test is about would never be observed.
789        let exited = Arc::new(AtomicUsize::new(0));
790        let child_exited = Arc::clone(&exited);
791        handle.spawn_worker("brief", async move {
792            child_exited.fetch_add(1, Ordering::SeqCst);
793        });
794        wait_until("the child has run and exited", || exited.load(Ordering::SeqCst) == 1).await;
795        supervisor.wait_for_children(0).await;
796
797        // The supervisor survived that exit: it is still running and still accepting work.
798        assert!(handle.is_running());
799        handle
800            .worker("second", std::future::pending::<()>())
801            .with_shutdown_strategy(ShutdownStrategy::Brutal)
802            .spawn();
803
804        assert!(supervisor.shutdown().await.is_ok());
805    }
806
807    #[tokio::test]
808    async fn a_significant_child_exiting_stops_the_supervisor() {
809        // The counterpart: a component supervisor uses `AutoShutdown::AnySignificant`, so a child marked significant
810        // takes the component with it when it terminates -- here on a perfectly clean exit, which is the part that
811        // surprises.
812        let supervisor = TestComponentSupervisor::start("test_component").await;
813        let handle = supervisor.handle();
814
815        handle.worker("brief", async {}).with_significant(true).spawn();
816
817        // Spawning only queues the child, so wait for the supervisor to actually stop itself rather than racing our
818        // own shutdown against the child ever starting.
819        wait_until("the supervisor has stopped", || !handle.is_running()).await;
820
821        let result = supervisor.shutdown().await;
822        assert!(
823            matches!(result, Err(SupervisorError::SignificantChildExited)),
824            "a significant child's exit should have stopped the supervisor, got {result:?}"
825        );
826    }
827
828    #[tokio::test]
829    async fn spawned_children_record_poll_metrics() {
830        // Every supervised worker's task is timed, and a child spawned through the builder is no exception. The tag is
831        // the child's fully qualified process name, which is what gives one series per name rather than per task.
832        let recorder = TestRecorder::default();
833        let _guard = metrics::set_default_local_recorder(&recorder);
834
835        // The recorder must be installed before the child is spawned: metric handles are resolved once, at spawn.
836        let supervisor = TestComponentSupervisor::start("metrics_component").await;
837
838        // Spawning only queues the child, so wait for it to actually run before shutting down -- otherwise there's no
839        // guarantee it was ever polled, and therefore none that it recorded anything.
840        let (ran_tx, ran_rx) = oneshot::channel();
841        supervisor.handle().spawn_worker("instrumented", async move {
842            let _ = ran_tx.send(());
843        });
844        ran_rx.await.expect("child should have run");
845
846        assert!(supervisor.shutdown().await.is_ok());
847
848        let polls = recorder.counter((
849            "runtime_task_poll_count",
850            &[("task_name", "metrics_component.instrumented")],
851        ));
852        assert!(
853            polls.is_some_and(|polls| polls > 0),
854            "spawned child should have recorded poll metrics, got {polls:?}"
855        );
856    }
857
858    #[tokio::test]
859    async fn supervisable_child_can_be_configured_before_spawning() {
860        // A `Supervisable` child goes through the same builder, including runtime placement.
861        let worker_pool_thread_id = Arc::new(Mutex::new(None));
862        let worker_pool_thread_id2 = Arc::clone(&worker_pool_thread_id);
863
864        let pool = tokio::runtime::Builder::new_multi_thread()
865            .worker_threads(1)
866            .on_thread_start(move || {
867                worker_pool_thread_id2
868                    .lock()
869                    .unwrap()
870                    .replace(std::thread::current().id());
871            })
872            .enable_all()
873            .build()
874            .expect("should build pool");
875
876        let supervisor = TestComponentSupervisor::start("test_component").await;
877
878        let (worker, initializations, worker_thread_id) = CountingWorker::new("counting");
879        supervisor
880            .handle()
881            .supervisable(worker)
882            .on_runtime(pool.handle().clone())
883            .spawn();
884
885        // `spawn` returns once the child is queued, and `initialize` runs inside the child's own task -- on the pool's
886        // runtime here -- so wait for it rather than assuming it has been polled.
887        supervisor.wait_for_children(1).await;
888        wait_until("the worker has initialized once", || {
889            initializations.load(Ordering::SeqCst) == 1
890        })
891        .await;
892
893        // Assert where it actually ran, not just that it ran: without this, `on_runtime` could be a no-op and the test
894        // would still pass.
895        let worker_pool_thread_id = worker_pool_thread_id
896            .lock()
897            .unwrap()
898            .expect("worker pool thread should have recorded its thread ID");
899        let worker_thread_id = worker_thread_id
900            .lock()
901            .unwrap()
902            .expect("worker should have recorded its thread ID");
903        assert_eq!(
904            worker_pool_thread_id, worker_thread_id,
905            "child should have run on the given runtime"
906        );
907
908        assert!(supervisor.shutdown().await.is_ok());
909        pool.shutdown_background();
910    }
911
912    #[tokio::test]
913    async fn supervisable_children_are_restarted_by_default() {
914        let supervisor = TestComponentSupervisor::start("test_component").await;
915
916        let (worker, initializations) = FailingOnceWorker::new();
917        supervisor.handle().spawn_supervisable(worker);
918
919        // The worker fails its first run, but then runs forever after that, so we should observe two initializations
920        // and no more after that.
921        wait_until("the worker has initialized twice", || {
922            initializations.load(Ordering::SeqCst) == 2
923        })
924        .await;
925        assert!(supervisor.shutdown().await.is_ok());
926    }
927
928    #[tokio::test]
929    async fn spawning_after_shutdown_never_starts_the_child() {
930        let supervisor = TestComponentSupervisor::start("test_component").await;
931        let handle = supervisor.handle();
932        assert!(supervisor.shutdown().await.is_ok());
933
934        let started = Arc::new(AtomicUsize::new(0));
935        let child_started = Arc::clone(&started);
936        handle.spawn_worker("late", async move {
937            child_started.fetch_add(1, Ordering::SeqCst);
938        });
939
940        tokio::time::sleep(Duration::from_millis(50)).await;
941        assert_eq!(
942            started.load(Ordering::SeqCst),
943            0,
944            "a child spawned against a stopped supervisor must never run"
945        );
946    }
947}