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}