saluki_core/components/
spawner.rs

1//! Utilities for spawning child tasks attached to specific components in a topology.
2use std::{future::Future, marker::PhantomData, time::Duration};
3
4use saluki_common::sync::shutdown::ShutdownHandle;
5use tokio::runtime::Handle;
6
7use crate::runtime::{
8    interruptible_worker, noninterruptible_worker, ChildId, ChildSpecification, IntoWorkerResult, RestartType,
9    ShutdownStrategy, SpawnError, Supervisable, SupervisorHandle, WorkerSpec,
10};
11
12/// Component-scoped spawner for child tasks.
13///
14/// Every component in a topology consists of a primary task which runs the "core loop" of the component, and optionally
15/// a number of child tasks that range from handling network connections to processing compute-heavy work in a separate
16/// thread pool.
17///
18/// [`ComponentSpawner`] provides a component-scoped mechanism for spawning those child tasks under supervision. It is
19/// tied specifically to the dedicated per-component supervisor that each component gets, which ensures that child tasks
20/// spawned through this mechanism are properly attributed to the component, and also that their lifecycle is one-to-one
21/// with the component itself.
22///
23/// # Child lifecycle, and one-shot vs supervisable
24///
25/// We classify tasks as either _one-shot_ or _supervisable_: one-shot tasks are those based on a provided closure,
26/// which cannot be reinitialized and so cannot be restarted, and supervisable tasks are those based on an implementation
27/// of [`Supervisable`], which allows for (potentially) initializing the underlying task future multiple times.
28///
29/// One-shot tasks are always [`temporary`][crate::runtime::RestartType::Temporary], since they cannot be
30/// reinitialized. Supervisable tasks default to the same, and opt into being restarted via
31/// [`ChildBuilder::with_restart_type`] when the worker is built to be initialized more than once.
32///
33/// All child tasks default to being marked as non-significant, so their termination -- clean or otherwise -- leaves the
34/// component running. This is usually the correct behavior, but a component that cannot function without a particular
35/// child may wish to mark it significant, which stops the component when that child terminates.
36///
37/// See [`ChildBuilder::with_significant`] for more information.
38///
39/// # Interruptible vs non-interruptible
40///
41/// [`ComponentSpawner`] allows spawning two styles of child task: "interruptible" and "non-interruptible."
42/// Interruptible tasks are wrapped such that when the supervisor signals shutdown, the shutdown signal is
43/// honored/polled despite whatever the logic is in the task itself does. Non-interruptible tasks still received a
44/// shutdown handle, but the task logic itself is responsible for honoring shutdown signals.
45///
46/// Non-interruptible tasks aren't _truly_ uninterrupible: following the normal behavior of async Rust and the behavior
47/// of futures, the future associated with a task can simply be no longer polled or dropped, _effectively_ interrupting
48/// it when considered at the level of "will this task run to completion?"
49///
50/// # Worker pool
51///
52/// [`ComponentSpawner`] is topology-aware, which means callers have the ability to specify a child task runs on the
53/// shared "global" thread pool attached to a given topology. This should be used for compute-heavy tasks, which
54/// otherwise can affect the scheduling latency of I/O-heavy tasks.
55///
56/// # Task naming
57///
58/// Child task names should generally _not_ contain unique patterns/tokens -- such as monotonic IDs or high-cardinality
59/// values -- as they are used for internal telemetry about the task. Generally, task names should be thought of as a
60/// category label: if a component spawns tasks for handling connections, it should prefer to name them like
61/// `conn_handler` instead of `conn_handler_<ID or IP>`.
62///
63/// A child task that must finish draining before the component stops:
64///
65/// ```no_run
66/// # use saluki_core::components::ComponentSpawner;
67/// # async fn drain(shutdown: saluki_common::sync::shutdown::ShutdownHandle) {}
68/// # async fn example(spawner: &ComponentSpawner) -> Result<(), Box<dyn std::error::Error>> {
69/// spawner.spawn_noninterruptible("queue_drainer", |shutdown| drain(shutdown)).await?;
70/// # Ok(())
71/// # }
72/// ```
73///
74/// A compute-heavy task that belongs on the shared worker pool, which needs the builder to say so:
75///
76/// ```no_run
77/// # use saluki_core::components::ComponentSpawner;
78/// # async fn encode() {}
79/// # async fn example(spawner: ComponentSpawner) -> Result<(), Box<dyn std::error::Error>> {
80/// spawner.interruptible("encoder", encode()).on_worker_pool().spawn().await?;
81/// # Ok(())
82/// # }
83/// ```
84#[derive(Clone)]
85pub struct ComponentSpawner {
86    handle: SupervisorHandle,
87    worker_pool: Handle,
88}
89
90impl ComponentSpawner {
91    /// Creates a new `ComponentSpawner`.
92    ///
93    /// `worker_pool` is the shared worker pool owned by the topology, used by children that opt in via
94    /// [`ChildBuilder::on_worker_pool`].
95    ///
96    /// The supervisor behind `handle` **MUST** carry a shutdown budget
97    /// ([`Supervisor::with_shutdown_budget`][crate::runtime::Supervisor::with_shutdown_budget]). Children spawned here
98    /// have no deadline of their own, so without one a child that ignores shutdown stalls the drain indefinitely.
99    pub fn new(handle: SupervisorHandle, worker_pool: Handle) -> Self {
100        Self { handle, worker_pool }
101    }
102
103    /// Creates a builder for an interruptible child task.
104    ///
105    /// Interruptible tasks are implicitly wrapped such that shutdown is polled alongside the underlying task future,
106    /// ensuring that shutdown is observed at the earliest possible moment. They are best used for work which has no
107    /// requirements on orderly shutdown, draining of remaining work, and so on.
108    ///
109    /// Use this method when advanced configuration of the underlying task is required. Otherwise, prefer
110    /// [`spawn_interruptible`][Self::spawn_interruptible].
111    pub fn interruptible<N, Fut>(&self, name: N, fut: Fut) -> ChildBuilder<'_>
112    where
113        N: Into<String>,
114        Fut: Future + Send + 'static,
115        Fut::Output: IntoWorkerResult,
116    {
117        ChildBuilder::one_shot(self, interruptible_worker(name, fut))
118    }
119
120    /// Creates a builder for a non-interruptible child task.
121    ///
122    /// Non-interruptible tasks are those which handle shutdown signals directly in order to precisely control when the
123    /// task completes. They are best used for tasks which must perform some operation, or operations, between the
124    /// receiving of a shutdown signal and completion.
125    ///
126    /// Non-interruptible tasks are not necessarily blocking: running a non-interruptible does not mean that it is guaranteed
127    /// to complete, only that it won't be wrapped in a way that tries to shutdown at the earliest possible moment.
128    ///
129    /// Use this method when advanced configuration of the underlying task is required. Otherwise, prefer
130    /// [`spawn_noninterruptible`][Self::spawn_noninterruptible].
131    pub fn noninterruptible<N, F, Fut>(&self, name: N, f: F) -> ChildBuilder<'_>
132    where
133        N: Into<String>,
134        F: FnOnce(ShutdownHandle) -> Fut + Send + 'static,
135        Fut: Future + Send + 'static,
136        Fut::Output: IntoWorkerResult,
137    {
138        ChildBuilder::one_shot(self, noninterruptible_worker(name, f))
139    }
140
141    /// Creates a builder for a supervisable child task.
142    ///
143    /// Supervisable tasks are those where the worker already implements [`Supervisable`], which lets
144    /// `ComponentSpawner` serve as a consistent control surface for spawning both arbitrary asynchronous functions
145    /// and more full-fledged workers.
146    ///
147    /// Supervisable tasks are set to permanently restart by default.
148    ///
149    /// Use this method when advanced configuration of the underlying task is required. Otherwise, prefer
150    /// [`spawn_supervisable`][Self::spawn_supervisable].
151    pub fn supervisable<T>(&self, worker: T) -> ChildBuilder<'_, Restartable>
152    where
153        T: Supervisable + 'static,
154    {
155        ChildBuilder::restartable(self, worker)
156    }
157
158    /// Spawns an interruptible child task.
159    ///
160    /// Interruptible tasks are implicitly wrapped such that shutdown is polled alongside the underlying task future,
161    /// ensuring that shutdown is observed at the earliest possible moment. They are best used for work which has no
162    /// requirements on orderly shutdown, draining of remaining work, and so on.
163    ///
164    /// Use [`interruptible`][Self::interruptible] when advanced configuration of the underlying task is required.
165    ///
166    /// # Errors
167    ///
168    /// If the component's supervisor isn't running, or the child specification is invalid, an error is returned.
169    pub async fn spawn_interruptible<N, Fut>(&self, name: N, fut: Fut) -> Result<ChildId, SpawnError>
170    where
171        N: Into<String>,
172        Fut: Future + Send + 'static,
173        Fut::Output: IntoWorkerResult,
174    {
175        self.interruptible(name, fut).spawn().await
176    }
177
178    /// Spawns a non-interruptible child task.
179    ///
180    /// Non-interruptible tasks are those which handle shutdown signals directly in order to precisely control when the
181    /// task completes. They are best used for tasks which must perform some operation, or operations, between the
182    /// receiving of a shutdown signal and completion.
183    ///
184    /// Non-interruptible tasks are not necessarily blocking: running a non-interruptible does not mean that it is guaranteed
185    /// to complete, only that it won't be wrapped in a way that tries to shutdown at the earliest possible moment.
186    ///
187    /// Use [`noninterruptible`][Self::noninterruptible] when advanced configuration of the underlying task is required.
188    ///
189    /// # Errors
190    ///
191    /// If the component's supervisor isn't running, or the child specification is invalid, an error is returned.
192    pub async fn spawn_noninterruptible<N, F, Fut>(&self, name: N, f: F) -> Result<ChildId, SpawnError>
193    where
194        N: Into<String>,
195        F: FnOnce(ShutdownHandle) -> Fut + Send + 'static,
196        Fut: Future + Send + 'static,
197        Fut::Output: IntoWorkerResult,
198    {
199        self.noninterruptible(name, f).spawn().await
200    }
201
202    /// Spawns a supervisable child task.
203    ///
204    /// Supervisable tasks are those where the worker already implements [`Supervisable`], which lets
205    /// `ComponentSpawner` serve as a consistent control surface for spawning both arbitrary asynchronous functions
206    /// and more full-fledged workers.
207    ///
208    /// Use [`supervisable`][Self::supervisable] when advanced configuration of the underlying task is required.
209    ///
210    /// # Errors
211    ///
212    /// If the component's supervisor isn't running, or the child specification is invalid, an error is returned.
213    pub async fn spawn_supervisable<T>(&self, worker: T) -> Result<ChildId, SpawnError>
214    where
215        T: Supervisable + 'static,
216    {
217        self.supervisable(worker).spawn().await
218    }
219
220    /// Returns a handle to the shared worker pool owned by the topology.
221    pub fn worker_pool(&self) -> &Handle {
222        &self.worker_pool
223    }
224
225    /// Returns the underlying supervisor handle.
226    pub fn handle(&self) -> &SupervisorHandle {
227        &self.handle
228    }
229
230    /// Returns the number of children currently running that were spawned through a spawner.
231    ///
232    /// Statically registered children -- the component itself, in a topology -- are not counted.
233    pub fn active_children(&self) -> usize {
234        self.handle.active_children()
235    }
236}
237
238mod sealed {
239    pub trait Sealed {}
240}
241
242/// The kind of worker a [`ChildBuilder`] is describing.
243///
244/// This trait is sealed, and exists only to mark which configuration a builder makes available: a worker that can be
245/// initialized more than once accepts a restart policy, and one that can't doesn't.
246pub trait BuilderState: sealed::Sealed {}
247
248/// Marks a builder whose worker can only be initialized once.
249///
250/// Closure-based children ([`ComponentSpawner::noninterruptible`], [`ComponentSpawner::interruptible`]) consume their
251/// body when they start, so they can never be restarted, and [`ChildBuilder::with_restart_type`] is not available.
252pub struct OneShot;
253
254/// Marks a builder whose worker can be initialized more than once.
255///
256/// A [`Supervisable`] builds its work in [`initialize`][Supervisable::initialize] each time it starts, so it can be
257/// restarted and [`ChildBuilder::with_restart_type`] is available.
258pub struct Restartable;
259
260impl sealed::Sealed for OneShot {}
261impl BuilderState for OneShot {}
262impl sealed::Sealed for Restartable {}
263impl BuilderState for Restartable {}
264
265/// Builder for a yet-to-be-spawned child task.
266///
267/// Advanced properties of a task can be configured with this builder prior to spawning. This builder uses the
268/// typestate pattern to control which properties of the child task that can be configured (by controlling which
269/// configuration methods are exposed) based on whether the worker is supervisable or not.
270///
271/// See [`BuilderState`] for more information on worker types.
272#[must_use = "a child is only started when `spawn` is called"]
273pub struct ChildBuilder<'a, S = OneShot> {
274    spawner: &'a ComponentSpawner,
275    spec: ChildSpecification<WorkerSpec>,
276    _state: PhantomData<S>,
277}
278
279impl<'a, S: BuilderState> ChildBuilder<'a, S> {
280    fn new(spawner: &'a ComponentSpawner, spec: ChildSpecification<WorkerSpec>) -> Self {
281        Self {
282            spawner,
283            spec,
284            _state: PhantomData,
285        }
286    }
287
288    fn map_spec<F>(self, f: F) -> Self
289    where
290        F: FnOnce(ChildSpecification<WorkerSpec>) -> ChildSpecification<WorkerSpec>,
291    {
292        let Self { spawner, spec, .. } = self;
293
294        Self::new(spawner, f(spec))
295    }
296
297    /// Runs this child task on the shared worker pool owned by the topology, instead of the component's runtime.
298    ///
299    /// Use this for compute-heavy work -- encoding, serialization, protocol servers -- that shouldn't contend with the
300    /// runtime that drives the supervisors and I/O for the topology.
301    pub fn on_worker_pool(self) -> Self {
302        let worker_pool = self.spawner.worker_pool.clone();
303        self.on_runtime(worker_pool)
304    }
305
306    /// Runs this child task on a specific runtime.
307    ///
308    /// Prefer [`on_worker_pool`][Self::on_worker_pool] unless the component owns a runtime of its own.
309    pub fn on_runtime(self, handle: Handle) -> Self {
310        self.map_spec(|spec| spec.with_runtime(handle))
311    }
312
313    /// Sets an explicit shutdown timeout for this child task.
314    ///
315    /// By default a closure-based child has no deadline of its own and is bounded only by the component's shutdown
316    /// budget. Set this when the component wants a particular child abandoned sooner than that -- a deadline it is
317    /// deliberately imposing, rather than a guess at how long the child ought to take. A value longer than the budget
318    /// has no effect, since the two are resolved to whichever elapses first.
319    ///
320    /// For a [`Supervisable`] child, this overrides the strategy the task reports for itself.
321    pub fn with_shutdown_timeout(self, timeout: Duration) -> Self {
322        self.with_shutdown_strategy(ShutdownStrategy::Graceful(timeout))
323    }
324
325    /// Sets the explicit shutdown strategy used for this child task.
326    pub fn with_shutdown_strategy(self, strategy: ShutdownStrategy) -> Self {
327        self.map_spec(|spec| spec.with_shutdown_strategy(strategy))
328    }
329
330    /// Sets whether this child task's termination should stop the component.
331    ///
332    /// A component's supervisor uses [`AutoShutdown::AnySignificant`][auto_shutdown], so a significant child
333    /// terminating **shuts the component down**: the child is not individually restarted. That happens even when the
334    /// child exits cleanly, so this suits a child the component cannot function without, and not one that is expected
335    /// to finish on its own.
336    ///
337    /// For example, a component handling client connections generally shouldn't stop just because one connection
338    /// failed, but a component forwarding work to a child task may become inoperable if that task dies and cannot be
339    /// reattached to the necessary channels or state without recreating the component.
340    ///
341    /// Only meaningful for a child that can terminate without being restarted, so setting it alongside
342    /// [`RestartType::Permanent`] has no effect: such a child is always restarted and so never reaches this path.
343    ///
344    /// Defaults to `false`.
345    ///
346    /// [auto_shutdown]: crate::runtime::AutoShutdown::AnySignificant
347    pub fn with_significant(self, significant: bool) -> Self {
348        self.map_spec(|spec| spec.with_significant(significant))
349    }
350
351    /// Spawns the child, returning once the supervisor has started it.
352    ///
353    /// # Errors
354    ///
355    /// Returns [`SpawnError::SupervisorGone`] if the component's supervisor isn't running, or [`SpawnError::Rejected`]
356    /// if it rejected the child (for example, an invalid name). A component's supervisor is running for the whole of
357    /// the component's `run`, so `SupervisorGone` in a component indicates that the topology is already being torn
358    /// down.
359    pub async fn spawn(self) -> Result<ChildId, SpawnError> {
360        let Self { spawner, spec, .. } = self;
361
362        spawner.handle.spawn_with(spec).await
363    }
364}
365
366impl<'a> ChildBuilder<'a, OneShot> {
367    fn one_shot<T>(spawner: &'a ComponentSpawner, worker: T) -> Self
368    where
369        T: Supervisable + 'static,
370    {
371        let spec = ChildSpecification::one_shot_worker(worker)
372            .with_restart_type(RestartType::Temporary)
373            .with_shutdown_strategy(ShutdownStrategy::Graceful(Duration::MAX));
374
375        Self::new(spawner, spec)
376    }
377}
378
379impl<'a> ChildBuilder<'a, Restartable> {
380    fn restartable<T>(spawner: &'a ComponentSpawner, worker: T) -> Self
381    where
382        T: Supervisable + 'static,
383    {
384        Self::new(spawner, ChildSpecification::worker(worker))
385    }
386
387    /// Sets the restart type for this child task.
388    ///
389    /// Supervised tasks that are defined through [`Supervisable`] default to being restarted permanently, since their
390    /// structure naturally exposes a mechanism to allow initializing a worker more than once. However, in some cases,
391    /// it may be desirable to disallow restarting a specific worker and instead treat their exit differently: only
392    /// restart when the worker exits abnormally, or never restart the worker, and so on.
393    ///
394    /// Defaults to [`RestartType::Permanent`].
395    pub fn with_restart_type(self, restart_type: RestartType) -> Self {
396        self.map_spec(|spec| spec.with_restart_type(restart_type))
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use std::{
403        sync::{
404            atomic::{AtomicUsize, Ordering},
405            Arc, Mutex,
406        },
407        thread::ThreadId,
408    };
409
410    use async_trait::async_trait;
411    use saluki_metrics::test::TestRecorder;
412    use tokio::sync::oneshot;
413    use tokio::time::timeout;
414
415    use super::*;
416    use crate::components::test_util::TestComponentSupervisor;
417    use crate::runtime::SupervisorError;
418
419    /// How long the drain-participating child in these tests takes to finish after observing shutdown.
420    ///
421    /// Long enough that a child given any short deadline of its own would be aborted before completing, so the tests
422    /// fail if children stop being bounded by the supervisor's budget alone.
423    const DRAIN_DURATION: Duration = Duration::from_millis(300);
424
425    /// A hand-written [`Supervisable`] that records where and how often it was initialized, then runs until shutdown.
426    struct CountingWorker {
427        name: &'static str,
428        initializations: Arc<AtomicUsize>,
429        thread_id: Arc<Mutex<Option<ThreadId>>>,
430    }
431
432    impl CountingWorker {
433        fn new(name: &'static str) -> (Self, Arc<AtomicUsize>, Arc<Mutex<Option<ThreadId>>>) {
434            let thread_id = Arc::new(Mutex::new(None));
435            let initializations = Arc::new(AtomicUsize::new(0));
436
437            (
438                Self {
439                    name,
440                    initializations: Arc::clone(&initializations),
441                    thread_id: Arc::clone(&thread_id),
442                },
443                initializations,
444                thread_id,
445            )
446        }
447    }
448
449    #[async_trait]
450    impl Supervisable for CountingWorker {
451        fn name(&self) -> &str {
452            self.name
453        }
454
455        fn shutdown_strategy(&self) -> ShutdownStrategy {
456            ShutdownStrategy::Graceful(Duration::MAX)
457        }
458
459        async fn initialize(
460            &self, process_shutdown: ShutdownHandle,
461        ) -> Result<crate::runtime::SupervisorFuture, crate::runtime::InitializationError> {
462            self.initializations.fetch_add(1, Ordering::SeqCst);
463            *self.thread_id.lock().unwrap() = Some(std::thread::current().id());
464
465            Ok(Box::pin(async move {
466                process_shutdown.await;
467                Ok(())
468            }))
469        }
470    }
471
472    /// A [`Supervisable`] that fails its first run and waits for shutdown on every run after.
473    struct FailingOnceWorker {
474        initializations: Arc<AtomicUsize>,
475    }
476
477    impl FailingOnceWorker {
478        fn new() -> (Self, Arc<AtomicUsize>) {
479            let initializations = Arc::new(AtomicUsize::new(0));
480            (
481                Self {
482                    initializations: Arc::clone(&initializations),
483                },
484                initializations,
485            )
486        }
487    }
488
489    #[async_trait]
490    impl Supervisable for FailingOnceWorker {
491        fn name(&self) -> &str {
492            "failing_once"
493        }
494
495        fn shutdown_strategy(&self) -> ShutdownStrategy {
496            ShutdownStrategy::Graceful(Duration::MAX)
497        }
498
499        async fn initialize(
500            &self, process_shutdown: ShutdownHandle,
501        ) -> Result<crate::runtime::SupervisorFuture, crate::runtime::InitializationError> {
502            let first_run = self.initializations.fetch_add(1, Ordering::SeqCst) == 0;
503
504            Ok(Box::pin(async move {
505                if first_run {
506                    return Err(saluki_error::generic_error!("first run always fails"));
507                }
508
509                process_shutdown.await;
510                Ok(())
511            }))
512        }
513    }
514
515    /// Polls `condition` until it holds, panicking after a few seconds.
516    async fn wait_for(mut condition: impl FnMut() -> bool) {
517        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
518        while !condition() {
519            assert!(tokio::time::Instant::now() < deadline, "condition never became true");
520            tokio::time::sleep(Duration::from_millis(5)).await;
521        }
522    }
523
524    #[tokio::test]
525    async fn noninterruptible_child_observes_shutdown_and_supervisor_exits_cleanly() {
526        // The child holds the component's grace period, observes shutdown, and finishes. A clean supervisor result is
527        // the assertion that matters: `ShutdownTimedOut` would mean the child was aborted rather than draining.
528        let supervisor = TestComponentSupervisor::start("test_component").await;
529        let spawner = supervisor.spawner();
530
531        // The child does real work *after* observing shutdown. Without that, it finishes within any grace period at
532        // all and the test would pass even if children were given a near-zero deadline instead of none.
533        let drained = Arc::new(AtomicUsize::new(0));
534        let child_drained = Arc::clone(&drained);
535
536        spawner
537            .spawn_noninterruptible("drainer", move |shutdown| async move {
538                shutdown.await;
539                tokio::time::sleep(DRAIN_DURATION).await;
540                child_drained.fetch_add(1, Ordering::SeqCst);
541            })
542            .await
543            .expect("should spawn");
544
545        supervisor.wait_for_children(1).await;
546
547        let result = supervisor.shutdown().await;
548        assert!(
549            result.is_ok(),
550            "child should have drained rather than been aborted: {result:?}"
551        );
552        assert_eq!(drained.load(Ordering::SeqCst), 1);
553    }
554
555    #[tokio::test]
556    async fn interruptible_child_is_torn_down_with_the_supervisor() {
557        // An interruptible child that would otherwise run forever must be dropped when the supervisor shuts down.
558        let supervisor = TestComponentSupervisor::start("test_component").await;
559
560        supervisor
561            .spawner()
562            .spawn_interruptible("forever", std::future::pending::<()>())
563            .await
564            .expect("should spawn");
565
566        supervisor.wait_for_children(1).await;
567        assert!(supervisor.shutdown().await.is_ok());
568    }
569
570    #[tokio::test]
571    async fn on_worker_pool_places_child_on_the_worker_pool() {
572        // `on_worker_pool` must actually change where the child's task runs. The spawner is built with an explicit
573        // pool handle here so the child's thread name is unambiguous.
574        let pool = tokio::runtime::Builder::new_multi_thread()
575            .worker_threads(1)
576            .thread_name("spawner-pool-test")
577            .enable_all()
578            .build()
579            .expect("should build pool");
580
581        let supervisor = TestComponentSupervisor::start("test_component").await;
582        let spawner = ComponentSpawner::new(supervisor.spawner().handle().clone(), pool.handle().clone());
583
584        let (thread_tx, thread_rx) = oneshot::channel();
585        spawner
586            .noninterruptible("pooled", move |shutdown| async move {
587                let _ = thread_tx.send(std::thread::current().name().unwrap_or_default().to_string());
588                shutdown.await;
589            })
590            .on_worker_pool()
591            .spawn()
592            .await
593            .expect("should spawn");
594
595        let thread_name = timeout(Duration::from_secs(5), thread_rx)
596            .await
597            .expect("child should report its thread promptly")
598            .expect("child should not be dropped before reporting");
599        assert!(
600            thread_name.starts_with("spawner-pool-test"),
601            "child must run on the worker pool, but ran on thread {thread_name:?}"
602        );
603
604        assert!(supervisor.shutdown().await.is_ok());
605        pool.shutdown_background();
606    }
607
608    #[tokio::test]
609    async fn child_exiting_does_not_shut_the_component_down() {
610        // Children are non-significant, so one finishing -- the normal case during a drain -- must not trip the
611        // supervisor's `AutoShutdown::AnySignificant` policy and tear the component down with it.
612        let supervisor = TestComponentSupervisor::start("test_component").await;
613
614        supervisor
615            .spawner()
616            .spawn_noninterruptible("brief", |_shutdown| async {})
617            .await
618            .expect("should spawn");
619
620        supervisor.wait_for_children(0).await;
621
622        // Still accepting work, so the supervisor is still running.
623        supervisor
624            .spawner()
625            .spawn_interruptible("second", std::future::pending::<()>())
626            .await
627            .expect("supervisor should still be running after a child exited");
628
629        assert!(supervisor.shutdown().await.is_ok());
630    }
631
632    #[tokio::test]
633    async fn spawned_children_record_poll_metrics() {
634        // Every supervised worker's task is timed, and a dynamically spawned child is no exception. The tag is the
635        // child's fully qualified process name, which is what gives one series per name rather than per task.
636        let recorder = TestRecorder::default();
637        let _guard = metrics::set_default_local_recorder(&recorder);
638
639        // The recorder must be installed before the child is spawned: metric handles are resolved once, at spawn.
640        let supervisor = TestComponentSupervisor::start("metrics_component").await;
641        supervisor
642            .spawner()
643            .spawn_noninterruptible("instrumented", |shutdown| shutdown)
644            .await
645            .expect("should spawn");
646        assert!(supervisor.shutdown().await.is_ok());
647
648        // Tagged with the child's fully qualified process name, matching what `spawn_traced_named` recorded.
649        let polls = recorder.counter((
650            "runtime_task_poll_count",
651            &[("task_name", "metrics_component.instrumented")],
652        ));
653        assert!(
654            polls.is_some_and(|polls| polls > 0),
655            "spawned child should have recorded poll metrics, got {polls:?}"
656        );
657    }
658
659    #[tokio::test]
660    async fn supervisable_child_can_be_configured_before_spawning() {
661        let worker_pool_thread_id = Arc::new(Mutex::new(None));
662        let worker_pool_thread_id2 = Arc::clone(&worker_pool_thread_id);
663
664        // Create a dedicated worker pool that we'll set as the worker pool on our spawner.
665        //
666        // This pool tracks the thread ID of the single worker thread we create, such that we can take the thread ID in
667        // our running task, and compare it to ensure the worker ran on the worker pool as intended.
668        let pool = tokio::runtime::Builder::new_multi_thread()
669            .worker_threads(1)
670            .on_thread_start(move || {
671                worker_pool_thread_id2
672                    .lock()
673                    .unwrap()
674                    .replace(std::thread::current().id());
675            })
676            .enable_all()
677            .build()
678            .expect("should build pool");
679
680        let supervisor = TestComponentSupervisor::start("test_component").await;
681        let spawner = ComponentSpawner::new(supervisor.spawner().handle().clone(), pool.handle().clone());
682
683        let (worker, initializations, worker_thread_id) = CountingWorker::new("counting");
684        spawner
685            .supervisable(worker)
686            .on_worker_pool()
687            .spawn()
688            .await
689            .expect("should spawn");
690
691        // `spawn` returns once the supervisor has registered the child, but `initialize` runs inside the child's own
692        // task -- on the pool's runtime here -- so wait for it rather than assuming it has been polled.
693        supervisor.wait_for_children(1).await;
694        wait_for(|| initializations.load(Ordering::SeqCst) == 1).await;
695
696        // Assert where it actually ran, not just that it ran: without this, `on_worker_pool` could be a no-op and the
697        // test would still pass.
698        let worker_pool_thread_id = worker_pool_thread_id
699            .lock()
700            .unwrap()
701            .expect("worker pool thread should have recorded its thread ID");
702        let worker_thread_id = worker_thread_id
703            .lock()
704            .unwrap()
705            .expect("worker should have recorded its thread ID");
706        assert_eq!(
707            worker_pool_thread_id, worker_thread_id,
708            "child should have run on the worker pool"
709        );
710
711        assert!(supervisor.shutdown().await.is_ok());
712        pool.shutdown_background();
713    }
714
715    #[tokio::test]
716    async fn supervisable_children_are_restarted_by_default() {
717        let supervisor = TestComponentSupervisor::start("test_component").await;
718
719        let (worker, initializations) = FailingOnceWorker::new();
720        supervisor
721            .spawner()
722            .supervisable(worker)
723            .spawn()
724            .await
725            .expect("should spawn");
726
727        // The worker fails its first run, but then runs forever after that, so we should observe two initializations
728        // and no more after that.
729        wait_for(|| initializations.load(Ordering::SeqCst) == 2).await;
730        assert!(supervisor.shutdown().await.is_ok());
731    }
732
733    #[tokio::test]
734    async fn one_shot_children_are_bounded_by_the_supervisors_budget() {
735        // A one-shot child carries no deadline of its own, so the supervisor's budget is what bounds it and a stuck
736        // child is aborted when the budget elapses.
737        //
738        // This deliberately does not try to distinguish that from a child having silently fallen back to the
739        // `Supervisable` trait default of five seconds: deadlines resolve to whichever elapses first, so any budget
740        // under five seconds produces an identical result, and telling them apart would need a test that runs for
741        // longer than five seconds. The distinction is unobservable in practice too -- a component supervisor's budget
742        // comes from the topology shutdown timeout, four seconds by default in ADP.
743        let supervisor = TestComponentSupervisor::start_with_budget("test_component", Duration::from_millis(200)).await;
744
745        supervisor
746            .spawner()
747            .spawn_noninterruptible("stuck", |_shutdown| std::future::pending::<()>())
748            .await
749            .expect("should spawn");
750        supervisor.wait_for_children(1).await;
751
752        let started = tokio::time::Instant::now();
753        let result = supervisor.shutdown().await;
754        let elapsed = started.elapsed();
755
756        assert!(
757            matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
758            "the budget should have aborted the stuck child, got {result:?}"
759        );
760        assert!(
761            elapsed < Duration::from_secs(1),
762            "the child should have been bounded by the 200ms budget rather than a deadline of its own; took {elapsed:?}"
763        );
764    }
765
766    #[tokio::test]
767    async fn a_significant_child_exiting_stops_the_component() {
768        // The counterpart to `child_exiting_does_not_shut_the_component_down`: a component supervisor uses
769        // `AutoShutdown::AnySignificant`, so a child marked significant takes the component with it when it terminates
770        // -- here on a perfectly clean exit, which is the part that surprises.
771        let supervisor = TestComponentSupervisor::start("test_component").await;
772
773        supervisor
774            .spawner()
775            .noninterruptible("brief", |_shutdown| async {})
776            .with_significant(true)
777            .spawn()
778            .await
779            .expect("should spawn");
780
781        let result = supervisor.shutdown().await;
782        assert!(
783            matches!(result, Err(SupervisorError::SignificantChildExited)),
784            "a significant child's exit should have stopped the supervisor, got {result:?}"
785        );
786    }
787
788    #[tokio::test]
789    async fn spawning_after_shutdown_reports_supervisor_gone() {
790        let supervisor = TestComponentSupervisor::start("test_component").await;
791        let spawner = supervisor.spawner();
792        assert!(supervisor.shutdown().await.is_ok());
793
794        let result = spawner.spawn_interruptible("late", std::future::pending::<()>()).await;
795        assert!(
796            matches!(result, Err(SpawnError::SupervisorGone)),
797            "spawning against a stopped supervisor should report `SupervisorGone`, got {result:?}"
798        );
799    }
800}