Skip to main content

saluki_core/topology/
blueprint.rs

1use std::{collections::HashMap, future::Future, num::NonZeroUsize, pin::Pin, sync::Mutex, time::Duration};
2
3use async_trait::async_trait;
4use saluki_common::resource_tracking::Track as _;
5use saluki_common::sync::shutdown::ShutdownHandle;
6use saluki_error::{generic_error, ErrorContext as _, GenericError};
7use snafu::Snafu;
8use tokio::{pin, runtime::Handle, select, sync::oneshot};
9use tracing::info;
10
11use super::{
12    built::{BuiltTopology, WorkerPoolConfiguration},
13    graph::{Graph, GraphError},
14    ComponentId,
15};
16use crate::accounting::{ComponentRegistry, MemoryLimiter, UsageExpr};
17use crate::{
18    components::{
19        decoders::DecoderBuilder, destinations::DestinationBuilder, encoders::EncoderBuilder,
20        forwarders::ForwarderBuilder, relays::RelayBuilder, sources::SourceBuilder, transforms::TransformBuilder,
21        BuildContext, ComponentContext, ComponentType,
22    },
23    data_model::event::Event,
24    health::HealthRegistry,
25    runtime::{
26        halt::HaltWatch,
27        state::{DataspaceRegistry, ResourceRegistry},
28        InitializationError, ShutdownStrategy, Supervisable, SupervisorFuture, UnadoptedScope,
29    },
30    support::SubsystemIdentifier,
31    topology::{ids::AsComponentIds, topology_identifier, EventsBuffer, DEFAULT_EVENTS_BUFFER_CAPACITY},
32};
33
34const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
35
36/// A topology blueprint error.
37#[derive(Debug, Snafu)]
38#[snafu(context(suffix(false)))]
39pub enum BlueprintError {
40    /// Adding a component/connection lead to an invalid graph.
41    #[snafu(display("Failed to build/validate topology graph: {}", source))]
42    InvalidGraph {
43        /// The underlying graph error.
44        source: GraphError,
45    },
46
47    /// Failed to build a component.
48    #[snafu(display("Failed to build component '{}': {}", id, source))]
49    FailedToBuildComponent {
50        /// Component ID for the component that failed to build.
51        id: ComponentId,
52
53        /// The underlying component build error.
54        source: GenericError,
55    },
56}
57
58/// A topology blueprint represents a directed graph of components.
59///
60/// A blueprint is assembled by adding components and connecting them together, and then run by adding it to a
61/// [`Supervisor`][crate::runtime::Supervisor]: `TopologyBlueprint` implements [`Supervisable`], so there is no
62/// standalone spawn/run method. A blueprint can only be initialized (and thus run) once.
63pub struct TopologyBlueprint {
64    name: String,
65    build_state: Mutex<Option<TopologyBuildState>>,
66    health_registry: Option<HealthRegistry>,
67    memory_limiter: Option<MemoryLimiter>,
68    resource_registry: Option<ResourceRegistry>,
69}
70
71/// The consumable build state of a [`TopologyBlueprint`].
72///
73/// This is taken out of the blueprint when it's first initialized, at which point the topology is built and spawned.
74struct TopologyBuildState {
75    topology_id: SubsystemIdentifier,
76    graph: Graph,
77    sources: HashMap<ComponentId, Box<dyn SourceBuilder + Send>>,
78    relays: HashMap<ComponentId, Box<dyn RelayBuilder + Send>>,
79    decoders: HashMap<ComponentId, Box<dyn DecoderBuilder + Send>>,
80    transforms: HashMap<ComponentId, Box<dyn TransformBuilder + Send>>,
81    destinations: HashMap<ComponentId, Box<dyn DestinationBuilder + Send>>,
82    encoders: HashMap<ComponentId, Box<dyn EncoderBuilder + Send>>,
83    forwarders: HashMap<ComponentId, Box<dyn ForwarderBuilder + Send>>,
84    component_registry: ComponentRegistry,
85    interconnect_capacity: NonZeroUsize,
86    shutdown_timeout: Duration,
87    worker_pool_config: WorkerPoolConfiguration,
88    environment_ready: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
89    ready_signal: Option<oneshot::Sender<()>>,
90}
91
92impl TopologyBlueprint {
93    /// Creates an empty `TopologyBlueprint` with the given name.
94    pub fn new(name: &str, component_registry: &ComponentRegistry) -> Self {
95        let topology_id = topology_identifier(name);
96        let component_registry = component_registry.clone();
97
98        let build_state = TopologyBuildState {
99            topology_id,
100            graph: Graph::default(),
101            sources: HashMap::new(),
102            relays: HashMap::new(),
103            decoders: HashMap::new(),
104            transforms: HashMap::new(),
105            destinations: HashMap::new(),
106            encoders: HashMap::new(),
107            forwarders: HashMap::new(),
108            component_registry,
109            interconnect_capacity: super::DEFAULT_INTERCONNECT_CAPACITY,
110            shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
111            worker_pool_config: WorkerPoolConfiguration::Dedicated,
112            environment_ready: None,
113            ready_signal: None,
114        };
115
116        Self {
117            name: name.to_string(),
118            build_state: Mutex::new(Some(build_state)),
119            health_registry: None,
120            memory_limiter: None,
121            resource_registry: None,
122        }
123    }
124
125    /// Gets a mutable reference to the build state.
126    ///
127    /// # Panics
128    ///
129    /// Panics if the blueprint has already been initialized (its build state having been consumed).
130    fn state_mut(&mut self) -> &mut TopologyBuildState {
131        self.build_state
132            .get_mut()
133            .expect("topology blueprint mutex poisoned")
134            .as_mut()
135            .expect("topology blueprint already initialized")
136    }
137
138    /// Sets the capacity of interconnects in the topology.
139    ///
140    /// Interconnects are used to connect components to one another. Once their capacity is reached, no more items can be sent
141    /// through until in-flight items are processed. This will apply backpressure to the upstream components. Raising or lowering
142    /// the capacity allows trading off throughput at the expense of memory usage.
143    ///
144    /// Defaults to 128.
145    pub fn with_interconnect_capacity(&mut self, capacity: NonZeroUsize) -> &mut Self {
146        self.state_mut().set_interconnect_capacity(capacity);
147        self
148    }
149
150    /// Sets how long the topology waits for components to stop during graceful shutdown.
151    ///
152    /// Defaults to 30 seconds.
153    pub fn with_shutdown_timeout(&mut self, timeout: Duration) -> &mut Self {
154        self.state_mut().shutdown_timeout = timeout;
155        self
156    }
157
158    /// Sets the health registry used when the topology is spawned.
159    ///
160    /// This must be set before the blueprint is added to a supervisor; initialization fails otherwise.
161    pub fn with_health_registry(&mut self, health_registry: HealthRegistry) -> &mut Self {
162        self.health_registry = Some(health_registry);
163        self
164    }
165
166    /// Sets the memory limiter used when the topology is spawned.
167    ///
168    /// This must be set before the blueprint is added to a supervisor; initialization fails otherwise.
169    pub fn with_memory_limiter(&mut self, memory_limiter: MemoryLimiter) -> &mut Self {
170        self.memory_limiter = Some(memory_limiter);
171        self
172    }
173
174    /// Sets the resource registry used when the topology is built and spawned.
175    ///
176    /// Components acquire scarce resources, such as bound network sockets, from this registry rather than creating
177    /// them directly, so that the registry outlives them and can hand the same resource back when a component is
178    /// rebuilt.
179    ///
180    /// This must be set before the blueprint is added to a supervisor; initialization fails otherwise.
181    pub fn with_resource_registry(&mut self, resource_registry: ResourceRegistry) -> &mut Self {
182        self.resource_registry = Some(resource_registry);
183        self
184    }
185
186    /// Sets a readiness signal that must resolve before the topology starts its components.
187    ///
188    /// When set, the topology is still built up front during initialization, but its components are not spawned until
189    /// the given future resolves (or the topology is asked to shut down first). This is used to defer the topology from
190    /// processing data until its dependencies -- such as the environment provider's metadata collectors -- are ready.
191    pub fn with_environment_readiness<F>(&mut self, ready: F) -> &mut Self
192    where
193        F: Future<Output = ()> + Send + 'static,
194    {
195        self.state_mut().environment_ready = Some(Box::pin(ready));
196        self
197    }
198
199    /// Returns a handle for awaiting the readiness of the topology once it's running.
200    ///
201    /// This handle depends on observing the readiness of the individual topology components, and so must be called after
202    /// [`with_health_registry`][Self::with_health_registry].
203    ///
204    /// # Panics
205    ///
206    /// Panics if the health registry has not been set, or if the blueprint has already been initialized.
207    pub fn topology_ready(&mut self) -> TopologyReady {
208        let health_registry = self
209            .health_registry
210            .clone()
211            .expect("health registry must be set before acquiring a topology readiness handle");
212        let component_root = super::topology_identifier(&self.name);
213
214        let (registered_tx, registered_rx) = oneshot::channel();
215        self.state_mut().ready_signal = Some(registered_tx);
216
217        TopologyReady {
218            registered_rx,
219            health_registry,
220            component_root,
221        }
222    }
223
224    /// Configures the topology to use the ambient Tokio runtime for component subtasks.
225    ///
226    /// Component subtasks will be spawned on whatever runtime is currently active when the topology is initialized.
227    /// This avoids creating a dedicated thread pool, which is useful for resource-constrained environments.
228    pub fn with_ambient_worker_pool(&mut self) -> &mut Self {
229        self.state_mut().worker_pool_config = WorkerPoolConfiguration::Ambient;
230        self
231    }
232
233    /// Configures the topology to use an externally provided Tokio runtime for component subtasks.
234    ///
235    /// Component subtasks will be spawned on the runtime associated with the given handle.
236    pub fn with_explicit_worker_pool(&mut self, handle: Handle) -> &mut Self {
237        self.state_mut().worker_pool_config = WorkerPoolConfiguration::Explicit(handle);
238        self
239    }
240
241    /// Adds a source component to the blueprint.
242    ///
243    /// # Errors
244    ///
245    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
246    pub fn add_source<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
247    where
248        I: AsRef<str>,
249        B: SourceBuilder + Send + 'static,
250    {
251        self.state_mut().add_source(component_id, builder)?;
252        Ok(self)
253    }
254
255    /// Adds a relay component to the blueprint.
256    ///
257    /// # Errors
258    ///
259    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
260    pub fn add_relay<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
261    where
262        I: AsRef<str>,
263        B: RelayBuilder + Send + 'static,
264    {
265        self.state_mut().add_relay(component_id, builder)?;
266        Ok(self)
267    }
268
269    /// Adds a decoder component to the blueprint.
270    ///
271    /// # Errors
272    ///
273    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
274    pub fn add_decoder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
275    where
276        I: AsRef<str>,
277        B: DecoderBuilder + Send + 'static,
278    {
279        self.state_mut().add_decoder(component_id, builder)?;
280        Ok(self)
281    }
282
283    /// Adds a transform component to the blueprint.
284    ///
285    /// # Errors
286    ///
287    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
288    pub fn add_transform<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
289    where
290        I: AsRef<str>,
291        B: TransformBuilder + Send + 'static,
292    {
293        self.state_mut().add_transform(component_id, builder)?;
294        Ok(self)
295    }
296
297    /// Adds a destination component to the blueprint.
298    ///
299    /// # Errors
300    ///
301    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
302    pub fn add_destination<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
303    where
304        I: AsRef<str>,
305        B: DestinationBuilder + Send + 'static,
306    {
307        self.state_mut().add_destination(component_id, builder)?;
308        Ok(self)
309    }
310
311    /// Adds an encoder component to the blueprint.
312    ///
313    /// # Errors
314    ///
315    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
316    pub fn add_encoder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
317    where
318        I: AsRef<str>,
319        B: EncoderBuilder + Send + 'static,
320    {
321        self.state_mut().add_encoder(component_id, builder)?;
322        Ok(self)
323    }
324
325    /// Adds a forwarder component to the blueprint.
326    ///
327    /// # Errors
328    ///
329    /// If the component ID is invalid or the component can't be added to the graph, an error is returned.
330    pub fn add_forwarder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
331    where
332        I: AsRef<str>,
333        B: ForwarderBuilder + Send + 'static,
334    {
335        self.state_mut().add_forwarder(component_id, builder)?;
336        Ok(self)
337    }
338
339    /// Connects one or more upstream component outputs to one or more downstream components.
340    ///
341    /// This method allows for ergonomically defining many-to-one, one-to-many, and many-to-many connections to
342    /// facilitate common patterns like fanning in many upstream components to a single downstream component, or fanning
343    /// out a single upstream component to many downstream components.
344    ///
345    /// When both there are both multiple upstream _and_ downstream component IDs, connections resemble a mesh: every
346    /// upstream component will be connected to every downstream component. This should be rare, but is technically
347    /// supported.
348    ///
349    /// # Errors
350    ///
351    /// If any of the upstream or downstream component IDs are invalid or don't exist, or if the data types between one
352    /// of the upstream/downstream component pairs is incompatible, an error is returned.
353    pub fn connect_components<MS, SI, MD, DI>(
354        &mut self, upstream_output_component_ids: SI, downstream_component_ids: DI,
355    ) -> Result<&mut Self, GenericError>
356    where
357        SI: AsComponentIds<MS>,
358        DI: AsComponentIds<MD>,
359    {
360        self.state_mut()
361            .connect_components(upstream_output_component_ids, downstream_component_ids)?;
362        Ok(self)
363    }
364
365    /// Connects a set of component IDs to one another in a pairwise fashion.
366    ///
367    /// This can be used to connect multiple components -- each sharing only a single edge between one another -- in a
368    /// single call instead of multiple calls.
369    ///
370    /// For example, passing `["first", "second", "third"]` would connect `first`'s output to `second`'s input, and
371    /// `second`'s output to `third`'s input.
372    ///
373    /// One caveat is that only the default output of a component can be used for connections past the first pair, as
374    /// the identifier given must be able to describe both the component ID to _send_ to as well as the component output
375    /// ID to connect to the subsequent component. This limitation does not exist on the first component ID, since it is
376    /// only used in the context of being a component output ID.
377    ///
378    /// # Errors
379    ///
380    /// If any of the component IDs are invalid or don't exist, or if the data types between one of the
381    /// upstream/downstream component pairs is incompatible, or if less than two component IDs are provided, an error is
382    /// returned.
383    ///
384    /// Care should be taken on failure as this method will not rollback any previously successful connections, which
385    /// could leave the blueprint in an indeterminate state if some connections are made prior to hitting an error.
386    pub fn connect_components_in_order<IT, I>(&mut self, ordered_component_ids: IT) -> Result<&mut Self, GenericError>
387    where
388        IT: IntoIterator<Item = I>,
389        I: AsRef<str>,
390    {
391        self.state_mut().connect_components_in_order(ordered_component_ids)?;
392        Ok(self)
393    }
394}
395
396/// A handle for awaiting the readiness of a running topology.
397pub struct TopologyReady {
398    registered_rx: oneshot::Receiver<()>,
399    health_registry: HealthRegistry,
400    component_root: SubsystemIdentifier,
401}
402
403impl TopologyReady {
404    /// Waits until the topology has registered its components and all of them have reported ready.
405    ///
406    /// Returns `true` once the topology is fully ready, or `false` if the topology was torn down before it finished
407    /// registering its components. The topology might be torn down before readiness is achieved if shutdown is
408    /// requested while still waiting on an upstream dependency such as the environment provider.
409    pub async fn wait(self) -> bool {
410        // First, wait for the topology to actually register its components in the health registry.
411        //
412        // If we didn't do this, we could observe `all_ready_matching` return immediately (due to no matching components)
413        // which would not correctly represent the topology being ready.
414        if self.registered_rx.await.is_err() {
415            return false;
416        }
417
418        // Now wait for all registered topology components to actually become ready.
419        self.health_registry.all_ready_under(self.component_root).await;
420
421        true
422    }
423}
424
425impl TopologyBuildState {
426    fn set_interconnect_capacity(&mut self, capacity: NonZeroUsize) {
427        self.interconnect_capacity = capacity;
428        self.recalculate_bounds();
429    }
430
431    fn recalculate_bounds(&mut self) {
432        let interconnect_capacity = self.interconnect_capacity.get();
433
434        let mut bounds_builder = self.component_registry.bounds_builder(&self.topology_id);
435        let mut bounds_builder = bounds_builder.subcomponent("interconnects");
436        bounds_builder.reset();
437
438        // Adjust the bounds related to interconnects.
439        //
440        // This deals with the minimum size of the interconnects themselves, since they're bounded and thus allocated
441        // up-front. Every non-source component has an interconnect.
442        let total_interconnect_capacity = interconnect_capacity * (self.transforms.len() + self.destinations.len());
443        bounds_builder
444            .minimum()
445            .with_array::<EventsBuffer>("events", total_interconnect_capacity);
446
447        // TODO: Add a minimum subitem for payloads when we have payload interconnects.
448
449        // Adjust the bounds related to event buffers themselves.
450        //
451        // We calculate the maximum number of event buffers by adding up the total capacity of all non-source components, plus the count
452        // of non-destination components. This is the effective upper bound because once all component channels are full, sending
453        // components can only allocate one more event buffer before being blocked on sending, which is then the effective upper bound.
454        //
455        // TODO: Somewhat fragile. Need to revisit this.
456        // TODO: Add a firm subitem for payloads when we have payload interconnects.
457        let max_in_flight_event_buffers = ((self.transforms.len() + self.destinations.len()) * interconnect_capacity)
458            + self.sources.len()
459            + self.decoders.len()
460            + self.transforms.len();
461
462        bounds_builder
463            .firm()
464            // max_in_flight_event_buffers * (size_of<EventsContainer> + (size_of<Event> * default_event_buffer_capacity))
465            .with_expr(UsageExpr::product(
466                "events",
467                UsageExpr::constant("max in-flight event buffers", max_in_flight_event_buffers),
468                UsageExpr::sum(
469                    "",
470                    UsageExpr::struct_size::<EventsBuffer>("events buffer"),
471                    UsageExpr::product(
472                        "",
473                        UsageExpr::struct_size::<Event>("event"),
474                        UsageExpr::constant("default event buffer capacity", DEFAULT_EVENTS_BUFFER_CAPACITY),
475                    ),
476                ),
477            ));
478    }
479
480    fn component_identity(&self, component_type: ComponentType, component_id: &ComponentId) -> SubsystemIdentifier {
481        ComponentContext::new(&self.topology_id, component_id.clone(), component_type).identity()
482    }
483
484    fn add_source<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
485    where
486        I: AsRef<str>,
487        B: SourceBuilder + Send + 'static,
488    {
489        let component_id = self
490            .graph
491            .add_source(component_id, &builder)
492            .error_context("Failed to add source to topology graph.")?;
493
494        let identity = self.component_identity(ComponentType::Source, &component_id);
495        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
496
497        self.recalculate_bounds();
498
499        let _ = self.sources.insert(component_id, Box::new(builder));
500
501        Ok(())
502    }
503
504    fn add_relay<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
505    where
506        I: AsRef<str>,
507        B: RelayBuilder + Send + 'static,
508    {
509        let component_id = self
510            .graph
511            .add_relay(component_id, &builder)
512            .error_context("Failed to add relay to topology graph.")?;
513
514        let identity = self.component_identity(ComponentType::Relay, &component_id);
515        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
516
517        self.recalculate_bounds();
518
519        let _ = self.relays.insert(component_id, Box::new(builder));
520
521        Ok(())
522    }
523
524    fn add_decoder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
525    where
526        I: AsRef<str>,
527        B: DecoderBuilder + Send + 'static,
528    {
529        let component_id = self
530            .graph
531            .add_decoder(component_id, &builder)
532            .error_context("Failed to add decoder to topology graph.")?;
533
534        let identity = self.component_identity(ComponentType::Decoder, &component_id);
535        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
536
537        self.recalculate_bounds();
538
539        let _ = self.decoders.insert(component_id, Box::new(builder));
540
541        Ok(())
542    }
543
544    fn add_transform<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
545    where
546        I: AsRef<str>,
547        B: TransformBuilder + Send + 'static,
548    {
549        let component_id = self
550            .graph
551            .add_transform(component_id, &builder)
552            .error_context("Failed to add transform to topology graph.")?;
553
554        let identity = self.component_identity(ComponentType::Transform, &component_id);
555        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
556
557        self.recalculate_bounds();
558
559        let _ = self.transforms.insert(component_id, Box::new(builder));
560
561        Ok(())
562    }
563
564    fn add_destination<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
565    where
566        I: AsRef<str>,
567        B: DestinationBuilder + Send + 'static,
568    {
569        let component_id = self
570            .graph
571            .add_destination(component_id, &builder)
572            .error_context("Failed to add destination to topology graph.")?;
573
574        let identity = self.component_identity(ComponentType::Destination, &component_id);
575        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
576
577        self.recalculate_bounds();
578
579        let _ = self.destinations.insert(component_id, Box::new(builder));
580
581        Ok(())
582    }
583
584    fn add_encoder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
585    where
586        I: AsRef<str>,
587        B: EncoderBuilder + Send + 'static,
588    {
589        let component_id = self
590            .graph
591            .add_encoder(component_id, &builder)
592            .error_context("Failed to add encoder to topology graph.")?;
593
594        let identity = self.component_identity(ComponentType::Encoder, &component_id);
595        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
596
597        self.recalculate_bounds();
598
599        let _ = self.encoders.insert(component_id, Box::new(builder));
600
601        Ok(())
602    }
603
604    fn add_forwarder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
605    where
606        I: AsRef<str>,
607        B: ForwarderBuilder + Send + 'static,
608    {
609        let component_id = self
610            .graph
611            .add_forwarder(component_id, &builder)
612            .error_context("Failed to add forwarder to topology graph.")?;
613
614        let identity = self.component_identity(ComponentType::Forwarder, &component_id);
615        builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
616
617        self.recalculate_bounds();
618
619        let _ = self.forwarders.insert(component_id, Box::new(builder));
620
621        Ok(())
622    }
623
624    fn connect_components<MS, SI, MD, DI>(
625        &mut self, upstream_output_component_ids: SI, downstream_component_ids: DI,
626    ) -> Result<(), GenericError>
627    where
628        SI: AsComponentIds<MS>,
629        DI: AsComponentIds<MD>,
630    {
631        for upstream_output_component_id in upstream_output_component_ids.as_component_ids() {
632            for downstream_component_id in downstream_component_ids.as_component_ids() {
633                self.graph
634                    .add_edge(upstream_output_component_id.as_ref(), downstream_component_id.as_ref())
635                    .error_context("Failed to add component connection to topology graph.")?;
636            }
637        }
638
639        Ok(())
640    }
641
642    fn connect_components_in_order<IT, I>(&mut self, ordered_component_ids: IT) -> Result<(), GenericError>
643    where
644        IT: IntoIterator<Item = I>,
645        I: AsRef<str>,
646    {
647        let mut pending_output_component_id: Option<I> = None;
648        let mut connected_any = false;
649
650        for component_id in ordered_component_ids.into_iter() {
651            if let Some(output_component_id) = pending_output_component_id.take() {
652                self.graph
653                    .add_edge(output_component_id.as_ref(), component_id.as_ref())
654                    .error_context("Failed to add component connection to topology graph.")?;
655
656                connected_any = true;
657            }
658
659            // Store the _current_ component ID so we can chain its connection to the next component, and so on.
660            pending_output_component_id = Some(component_id);
661        }
662
663        // Make sure we connected at least one pair of components together, otherwise this is an invalid connection attempt.
664        if !connected_any {
665            return Err(generic_error!(
666                "Two or more components must be provided for connection."
667            ));
668        }
669
670        Ok(())
671    }
672
673    /// Builds the topology.
674    ///
675    /// # Errors
676    ///
677    /// If any of the components couldn't be built, an error is returned.
678    async fn build(self, name: String, resource_registry: &ResourceRegistry) -> Result<BuiltTopology, GenericError> {
679        self.graph.validate().error_context("Failed to build topology graph.")?;
680
681        let mut component_scopes = HashMap::new();
682
683        let mut sources = HashMap::new();
684        for (id, builder) in self.sources {
685            let component_context = ComponentContext::source(&self.topology_id, id.clone());
686            let allocation_token = self
687                .component_registry
688                .get_resource_group_token(&component_context.identity());
689            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
690            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
691            // component.
692            let scope = UnadoptedScope::new(component_context.identity().to_string());
693            let source = scope
694                .scope()
695                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
696                .track_resources(allocation_token)
697                .await
698                .with_error_context(|| format!("Failed to build source '{}'.", id))?;
699
700            component_scopes.insert(component_context.clone(), scope);
701            sources.insert(component_context, (source, self.component_registry.clone()));
702        }
703
704        let mut relays = HashMap::new();
705        for (id, builder) in self.relays {
706            let component_context = ComponentContext::relay(&self.topology_id, id.clone());
707            let allocation_token = self
708                .component_registry
709                .get_resource_group_token(&component_context.identity());
710            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
711            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
712            // component.
713            let scope = UnadoptedScope::new(component_context.identity().to_string());
714            let relay = scope
715                .scope()
716                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
717                .track_resources(allocation_token)
718                .await
719                .with_error_context(|| format!("Failed to build relay '{}'.", id))?;
720
721            component_scopes.insert(component_context.clone(), scope);
722            relays.insert(component_context, (relay, self.component_registry.clone()));
723        }
724
725        let mut decoders = HashMap::new();
726        for (id, builder) in self.decoders {
727            let component_context = ComponentContext::decoder(&self.topology_id, id.clone());
728            let allocation_token = self
729                .component_registry
730                .get_resource_group_token(&component_context.identity());
731            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
732            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
733            // component.
734            let scope = UnadoptedScope::new(component_context.identity().to_string());
735            let decoder = scope
736                .scope()
737                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
738                .track_resources(allocation_token)
739                .await
740                .with_error_context(|| format!("Failed to build decoder '{}'.", id))?;
741
742            component_scopes.insert(component_context.clone(), scope);
743            decoders.insert(component_context, (decoder, self.component_registry.clone()));
744        }
745
746        let mut transforms = HashMap::new();
747        for (id, builder) in self.transforms {
748            let component_context = ComponentContext::transform(&self.topology_id, id.clone());
749            let allocation_token = self
750                .component_registry
751                .get_resource_group_token(&component_context.identity());
752            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
753            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
754            // component.
755            let scope = UnadoptedScope::new(component_context.identity().to_string());
756            let transform = scope
757                .scope()
758                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
759                .track_resources(allocation_token)
760                .await
761                .with_error_context(|| format!("Failed to build transform '{}'.", id))?;
762
763            component_scopes.insert(component_context.clone(), scope);
764            transforms.insert(component_context, (transform, self.component_registry.clone()));
765        }
766
767        let mut destinations = HashMap::new();
768        for (id, builder) in self.destinations {
769            let component_context = ComponentContext::destination(&self.topology_id, id.clone());
770            let allocation_token = self
771                .component_registry
772                .get_resource_group_token(&component_context.identity());
773            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
774            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
775            // component.
776            let scope = UnadoptedScope::new(component_context.identity().to_string());
777            let destination = scope
778                .scope()
779                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
780                .track_resources(allocation_token)
781                .await
782                .with_error_context(|| format!("Failed to build destination '{}'.", id))?;
783
784            component_scopes.insert(component_context.clone(), scope);
785            destinations.insert(component_context, (destination, self.component_registry.clone()));
786        }
787
788        let mut encoders = HashMap::new();
789        for (id, builder) in self.encoders {
790            let component_context = ComponentContext::encoder(&self.topology_id, id.clone());
791            let allocation_token = self
792                .component_registry
793                .get_resource_group_token(&component_context.identity());
794            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
795            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
796            // component.
797            let scope = UnadoptedScope::new(component_context.identity().to_string());
798            let encoder = scope
799                .scope()
800                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
801                .track_resources(allocation_token)
802                .await
803                .with_error_context(|| format!("Failed to build encoder '{}'.", id))?;
804
805            component_scopes.insert(component_context.clone(), scope);
806            encoders.insert(component_context, (encoder, self.component_registry.clone()));
807        }
808
809        let mut forwarders = HashMap::new();
810        for (id, builder) in self.forwarders {
811            let component_context = ComponentContext::forwarder(&self.topology_id, id.clone());
812            let allocation_token = self
813                .component_registry
814                .get_resource_group_token(&component_context.identity());
815            // While the component is built, it can spawn tasks into the current scope, for example the background tasks
816            // of a cache. This scope holds these tasks until the component runs. Then the tasks belong to the
817            // component.
818            let scope = UnadoptedScope::new(component_context.identity().to_string());
819            let forwarder = scope
820                .scope()
821                .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
822                .track_resources(allocation_token)
823                .await
824                .with_error_context(|| format!("Failed to build forwarder '{}'.", id))?;
825
826            component_scopes.insert(component_context.clone(), scope);
827            forwarders.insert(component_context, (forwarder, self.component_registry.clone()));
828        }
829
830        let topology_token = self.component_registry.get_resource_group_token(&self.topology_id);
831
832        Ok(BuiltTopology::from_parts(
833            name,
834            self.topology_id,
835            self.graph,
836            sources,
837            relays,
838            decoders,
839            transforms,
840            destinations,
841            encoders,
842            forwarders,
843            component_scopes,
844            topology_token,
845            self.interconnect_capacity,
846            self.worker_pool_config,
847        ))
848    }
849}
850
851#[async_trait]
852impl Supervisable for TopologyBlueprint {
853    fn name(&self) -> &str {
854        &self.name
855    }
856
857    fn shutdown_strategy(&self) -> ShutdownStrategy {
858        // Each component's own shutdown timeout bounds the topology, so we don't need one.
859        ShutdownStrategy::Graceful(Duration::MAX)
860    }
861
862    fn can_restart(&self) -> bool {
863        // The first start consumes the build state, so a topology can only be run once.
864        false
865    }
866
867    async fn initialize(&self, shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
868        // Consume the build state.
869        //
870        // Topologies currently can't be initialized more than once.
871        let mut build_state = self
872            .build_state
873            .lock()
874            .expect("topology blueprint mutex poisoned")
875            .take()
876            .ok_or_else(|| generic_error!("Topology has already been initialized and cannot be run more than once."))?;
877
878        let health_registry = self
879            .health_registry
880            .clone()
881            .ok_or_else(|| generic_error!("Topology blueprint is missing its health registry."))?;
882        let memory_limiter = self
883            .memory_limiter
884            .clone()
885            .ok_or_else(|| generic_error!("Topology blueprint is missing its memory limiter."))?;
886
887        let resource_registry = self
888            .resource_registry
889            .clone()
890            .ok_or_else(|| generic_error!("Topology blueprint is missing its resource registry."))?;
891
892        let dataspace = DataspaceRegistry::try_current()
893            .ok_or_else(|| generic_error!("Topology must be initialized within a supervised process context."))?;
894
895        // Build our topology components.
896        //
897        // This creates the topology components but does not actually spawn them or run them in any way.
898        //
899        // We do this outside of the supervisor future to ensure that we fail during initialization, which bubbles up as
900        // a non-restartable error that ultimately leads to the process exiting. This is the desired behavior at present
901        // time, but maybe change in the future.
902        let environment_ready = build_state.environment_ready.take();
903        let ready_signal = build_state.ready_signal.take();
904        let shutdown_timeout = build_state.shutdown_timeout;
905        let built = build_state.build(self.name.clone(), &resource_registry).await?;
906
907        Ok(Box::pin(async move {
908            pin!(shutdown);
909
910            // If a readiness signal was provided, wait for it before building the components, but remain responsive to
911            // shutdown so we exit promptly if asked to stop before we've started.
912            if let Some(environment_ready) = environment_ready {
913                select! {
914                    _ = &mut shutdown => return Ok(()),
915                    _ = environment_ready => {},
916                }
917            }
918
919            // Build the topology supervisor. It is a single supervisor that owns failure detection and graceful
920            // shutdown, with one worker for each component. Each worker owns everything that it spawns.
921            let mut topology_sup = built
922                .spawn_inner(&health_registry, memory_limiter, dataspace.clone(), shutdown_timeout)
923                .await?;
924
925            // Signal that the topology has registered all of its components in the health registry, so any readiness
926            // handle can begin waiting on those components. `spawn_inner` registers every component before returning, so
927            // readiness can't be observed before the topology's components exist.
928            if let Some(ready_signal) = ready_signal {
929                let _ = ready_signal.send(());
930            }
931
932            // Run the topology supervisor, forwarding our own shutdown signal into it. We run it here, and not as our
933            // own child, so that its process name stays rooted at the identifier of the topology. This makes the
934            // process name of each component its canonical identity. We use the internal variant so we can pass down
935            // the inherited dataspace (the public `run_with_shutdown` would create a fresh, empty one). The topology
936            // supervisor returns `Ok(())` on an intentional shutdown that drained cleanly, and an error if any
937            // component exits on its own (how a component failure fails the topology) or if a component had to be
938            // forcefully aborted after ignoring graceful shutdown (`SupervisorError::ShutdownTimedOut`). That error is
939            // preserved here as a `GenericError` so the root supervisor can still recover the aborted-worker count.
940            //
941            // Nothing halts the topology supervisor, since each component is bounded by its own timeout.
942            let (topology_shutdown_trigger, topology_shutdown) = ShutdownHandle::paired();
943            let run = topology_sup.run_with_shutdown_inner(topology_shutdown, HaltWatch::never(), Some(dataspace));
944            pin!(run);
945
946            let mut topology_shutdown_trigger = Some(topology_shutdown_trigger);
947            loop {
948                select! {
949                    result = &mut run => return result.map_err(Into::into),
950                    _ = &mut shutdown, if topology_shutdown_trigger.is_some() => {
951                        info!("Topology received shutdown signal. Shutting down...");
952                        topology_shutdown_trigger.take().expect("present per select guard").shutdown();
953                    }
954                }
955            }
956        }))
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    use std::num::NonZeroUsize;
963    use std::sync::atomic::{AtomicUsize, Ordering};
964    use std::sync::Arc;
965    use std::time::Duration;
966
967    use async_trait::async_trait;
968    use saluki_common::sync::shutdown::ShutdownHandle;
969    use saluki_error::GenericError;
970    use tokio::sync::oneshot;
971
972    use super::{TopologyBlueprint, TopologyReady, WorkerPoolConfiguration};
973    use crate::accounting::{ComponentRegistry, MemoryBounds, MemoryBoundsBuilder, MemoryLimiter};
974    use crate::components::BuildContext;
975    use crate::data_model::event::Event;
976    use crate::runtime::{self, state::ResourceRegistry, Name};
977    use crate::test_support::wait_until;
978    use crate::topology::{ids::get_component_relative_identifier, topology_identifier, ComponentId};
979    use crate::topology::{EventsBuffer, DEFAULT_EVENTS_BUFFER_CAPACITY};
980    use crate::{
981        components::{
982            destinations::{Destination, DestinationBuilder, DestinationContext},
983            sources::{Source, SourceBuilder, SourceContext},
984            ComponentContext,
985        },
986        data_model::event::EventType,
987        health::HealthRegistry,
988        runtime::{InitializationError, RestartStrategy, Supervisable, Supervisor, SupervisorError, SupervisorFuture},
989        support::SubsystemIdentifier,
990        topology::{
991            test_util::{TestDestinationBuilder, TestSourceBuilder, TestTransformBuilder},
992            OutputDefinition,
993        },
994    };
995
996    /// A dynamic child worker for exercising [`SourceContext::spawn_handle`].
997    ///
998    /// Records that it started, then runs until its supervisor shuts it down.
999    struct CountingChild {
1000        started: Arc<AtomicUsize>,
1001    }
1002
1003    #[async_trait]
1004    impl Supervisable for CountingChild {
1005        fn name(&self) -> &str {
1006            "child"
1007        }
1008
1009        async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
1010            let started = Arc::clone(&self.started);
1011            Ok(Box::pin(async move {
1012                started.fetch_add(1, Ordering::SeqCst);
1013                // Run until torn down, so the test can observe structured teardown of dynamic children.
1014                process_shutdown.await;
1015                Ok(())
1016            }))
1017        }
1018    }
1019
1020    /// A source that runs until shutdown, optionally spawning a dynamic child on its ambient supervisor first.
1021    ///
1022    /// Records that it started (so tests can wait for readiness rather than sleeping) before running.
1023    struct ControlSource {
1024        started: Arc<AtomicUsize>,
1025        spawned_child: Option<Arc<AtomicUsize>>,
1026    }
1027
1028    #[async_trait]
1029    impl Source for ControlSource {
1030        async fn run(self: Box<Self>, mut context: SourceContext) -> Result<(), GenericError> {
1031            self.started.fetch_add(1, Ordering::SeqCst);
1032            let shutdown = context.take_shutdown_handle();
1033            if let Some(started) = self.spawned_child {
1034                // Ambient spawning is how a component reaches its own supervisor: nothing is threaded to it, and the
1035                // child still has to land under this component rather than anywhere else.
1036                runtime::supervisable(CountingChild { started }).spawn();
1037            }
1038            shutdown.await;
1039            Ok(())
1040        }
1041    }
1042
1043    struct ControlSourceBuilder {
1044        outputs: Vec<OutputDefinition<EventType>>,
1045        started: Arc<AtomicUsize>,
1046        spawned_child: Option<Arc<AtomicUsize>>,
1047    }
1048
1049    impl ControlSourceBuilder {
1050        fn new(started: Arc<AtomicUsize>, spawned_child: Option<Arc<AtomicUsize>>) -> Self {
1051            Self {
1052                outputs: vec![OutputDefinition::default_output(EventType::EventD)],
1053                started,
1054                spawned_child,
1055            }
1056        }
1057    }
1058
1059    #[async_trait]
1060    impl SourceBuilder for ControlSourceBuilder {
1061        fn outputs(&self) -> &[OutputDefinition<EventType>] {
1062            &self.outputs
1063        }
1064
1065        async fn build(&self, _: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
1066            Ok(Box::new(ControlSource {
1067                started: Arc::clone(&self.started),
1068                spawned_child: self.spawned_child.clone(),
1069            }))
1070        }
1071    }
1072
1073    impl MemoryBounds for ControlSourceBuilder {
1074        fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1075    }
1076
1077    /// A source whose builder spawns a child into the current scope while it builds the source.
1078    ///
1079    /// The child records when it starts and when it stops. It runs until something tells it to stop.
1080    struct BuildSpawningSourceBuilder {
1081        outputs: Vec<OutputDefinition<EventType>>,
1082        source_started: Arc<AtomicUsize>,
1083        child_started: Arc<AtomicUsize>,
1084        child_stopped: Arc<AtomicUsize>,
1085    }
1086
1087    #[async_trait]
1088    impl SourceBuilder for BuildSpawningSourceBuilder {
1089        fn outputs(&self) -> &[OutputDefinition<EventType>] {
1090            &self.outputs
1091        }
1092
1093        async fn build(&self, _: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
1094            let (started, stopped) = (Arc::clone(&self.child_started), Arc::clone(&self.child_stopped));
1095            runtime::worker_with_shutdown("built_child", move |shutdown| async move {
1096                started.fetch_add(1, Ordering::SeqCst);
1097                shutdown.await;
1098                stopped.fetch_add(1, Ordering::SeqCst);
1099            })
1100            .spawn_or_detached();
1101
1102            Ok(Box::new(ControlSource {
1103                started: Arc::clone(&self.source_started),
1104                spawned_child: None,
1105            }))
1106        }
1107    }
1108
1109    impl MemoryBounds for BuildSpawningSourceBuilder {
1110        fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1111    }
1112
1113    /// A destination that drains its input until the upstream closes (which happens when the source stops).
1114    struct DrainingDestination;
1115
1116    #[async_trait]
1117    impl Destination for DrainingDestination {
1118        async fn run(self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
1119            while context.events().next().await.is_some() {}
1120            Ok(())
1121        }
1122    }
1123
1124    struct DrainingDestinationBuilder {
1125        input_event_ty: EventType,
1126    }
1127
1128    #[async_trait]
1129    impl DestinationBuilder for DrainingDestinationBuilder {
1130        fn input_event_type(&self) -> EventType {
1131            self.input_event_ty
1132        }
1133
1134        async fn build(&self, _: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1135            Ok(Box::new(DrainingDestination))
1136        }
1137    }
1138
1139    impl MemoryBounds for DrainingDestinationBuilder {
1140        fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1141    }
1142
1143    /// A destination that ignores both its input and shutdown, running forever until it is forcefully aborted.
1144    ///
1145    /// Records that it started (so tests can wait for it to actually be running before triggering shutdown).
1146    struct StuckDestination {
1147        started: Arc<AtomicUsize>,
1148    }
1149
1150    #[async_trait]
1151    impl Destination for StuckDestination {
1152        async fn run(self: Box<Self>, _context: DestinationContext) -> Result<(), GenericError> {
1153            self.started.fetch_add(1, Ordering::SeqCst);
1154            std::future::pending::<()>().await;
1155            Ok(())
1156        }
1157    }
1158
1159    struct StuckDestinationBuilder {
1160        input_event_ty: EventType,
1161        started: Arc<AtomicUsize>,
1162    }
1163
1164    #[async_trait]
1165    impl DestinationBuilder for StuckDestinationBuilder {
1166        fn input_event_type(&self) -> EventType {
1167            self.input_event_ty
1168        }
1169
1170        async fn build(&self, _: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1171            Ok(Box::new(StuckDestination {
1172                started: Arc::clone(&self.started),
1173            }))
1174        }
1175    }
1176
1177    impl MemoryBounds for StuckDestinationBuilder {
1178        fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1179    }
1180
1181    /// Builds a connected `source` -> `destination` blueprint of long-running components.
1182    ///
1183    /// The source runs until shutdown; the destination drains until its upstream closes. If `spawned_child` is
1184    /// provided, the source spawns a dynamic child through its spawn handle that records when it starts.
1185    ///
1186    /// Returns the blueprint together with the source's "started" counter, so a test can wait for the source to
1187    /// actually be running (readiness polling) rather than sleeping for a fixed duration.
1188    fn long_running_blueprint(spawned_child: Option<Arc<AtomicUsize>>) -> (TopologyBlueprint, Arc<AtomicUsize>) {
1189        let source_started = Arc::new(AtomicUsize::new(0));
1190        let component_registry = ComponentRegistry::default();
1191        let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1192        blueprint
1193            .add_source(
1194                "source",
1195                ControlSourceBuilder::new(Arc::clone(&source_started), spawned_child),
1196            )
1197            .expect("should not fail to add source")
1198            .add_destination(
1199                "destination",
1200                DrainingDestinationBuilder {
1201                    input_event_ty: EventType::EventD,
1202                },
1203            )
1204            .expect("should not fail to add destination");
1205        blueprint
1206            .connect_components_in_order(["source", "destination"])
1207            .expect("should not fail to connect components");
1208        blueprint
1209            .with_health_registry(HealthRegistry::new())
1210            .with_memory_limiter(MemoryLimiter::noop())
1211            .with_resource_registry(ResourceRegistry::new());
1212        (blueprint, source_started)
1213    }
1214
1215    /// Builds a connected `source` -> `destination` blueprint whose destination must be forcefully aborted on shutdown.
1216    ///
1217    /// The source stops cleanly when it gets the shutdown signal. The destination ignores shutdown and runs forever, so
1218    /// the topology supervisor aborts it after `shutdown_timeout`. Thus, exactly one component is aborted: the
1219    /// destination. This lets a test assert an exact abort count.
1220    ///
1221    /// Returns the blueprint together with the destination's "started" counter, so a test can wait until the stuck
1222    /// destination is actually running before triggering shutdown.
1223    fn stuck_destination_blueprint(shutdown_timeout: Duration) -> (TopologyBlueprint, Arc<AtomicUsize>) {
1224        let destination_started = Arc::new(AtomicUsize::new(0));
1225        let component_registry = ComponentRegistry::default();
1226        let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1227        blueprint
1228            .add_source("source", ControlSourceBuilder::new(Arc::new(AtomicUsize::new(0)), None))
1229            .expect("should not fail to add source")
1230            .add_destination(
1231                "destination",
1232                StuckDestinationBuilder {
1233                    input_event_ty: EventType::EventD,
1234                    started: Arc::clone(&destination_started),
1235                },
1236            )
1237            .expect("should not fail to add destination");
1238        blueprint
1239            .connect_components_in_order(["source", "destination"])
1240            .expect("should not fail to connect components");
1241        blueprint
1242            .with_health_registry(HealthRegistry::new())
1243            .with_memory_limiter(MemoryLimiter::noop())
1244            .with_resource_registry(ResourceRegistry::new())
1245            .with_shutdown_timeout(shutdown_timeout);
1246        (blueprint, destination_started)
1247    }
1248
1249    /// Spawns `blueprint` under a fresh `test-topology` supervisor, returning the shutdown sender and the run's join
1250    /// handle.
1251    ///
1252    /// This is the shared spawn/shutdown scaffold used by the topology-lifecycle tests below, replacing the
1253    /// copy-pasted supervisor construction repeated across them.
1254    fn spawn_supervised_blueprint(
1255        blueprint: TopologyBlueprint,
1256    ) -> (
1257        oneshot::Sender<()>,
1258        tokio::task::JoinHandle<Result<(), SupervisorError>>,
1259    ) {
1260        let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1261        supervisor.add_worker(blueprint);
1262
1263        let (tx, rx) = oneshot::channel::<()>();
1264        let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1265        (tx, handle)
1266    }
1267
1268    /// Runs `blueprint` under a fresh `test-topology` supervisor with the given restart strategy until it exits on its
1269    /// own (no shutdown is ever signalled), returning the supervisor's result.
1270    async fn run_blueprint_until_exit(
1271        blueprint: TopologyBlueprint, strategy: RestartStrategy,
1272    ) -> Result<(), SupervisorError> {
1273        // The supervisor needs the topology, as the root supervisor of the real binary does.
1274        let mut supervisor = Supervisor::new("test-topology")
1275            .expect("should not fail to create supervisor")
1276            .with_restart_strategy(strategy);
1277        supervisor.add_worker(runtime::supervisable(blueprint).needed().build());
1278
1279        // Hold the sender so shutdown is never triggered; the supervisor exits only when the topology does.
1280        let (_tx, rx) = oneshot::channel::<()>();
1281        tokio::time::timeout(Duration::from_secs(5), supervisor.run_with_shutdown(rx))
1282            .await
1283            .expect("supervisor should exit promptly")
1284    }
1285
1286    /// Awaits a spawned topology-supervisor run to completion under a bounded timeout, unwrapping the join.
1287    async fn join_topology(
1288        handle: tokio::task::JoinHandle<Result<(), SupervisorError>>,
1289    ) -> Result<(), SupervisorError> {
1290        tokio::time::timeout(Duration::from_secs(5), handle)
1291            .await
1292            .expect("supervisor should exit promptly")
1293            .expect("supervisor task should not panic")
1294    }
1295
1296    /// Builds a blueprint pre-populated with a source, transform, and destination, all dealing in event-D events.
1297    ///
1298    /// No connections are made between the components.
1299    fn blueprint_with_components() -> TopologyBlueprint {
1300        let component_registry = ComponentRegistry::default();
1301        let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1302
1303        blueprint
1304            .add_source("source", TestSourceBuilder::default_output(EventType::EventD))
1305            .expect("should not fail to add source")
1306            .add_transform(
1307                "transform",
1308                TestTransformBuilder::default_output(EventType::EventD, EventType::EventD),
1309            )
1310            .expect("should not fail to add transform")
1311            .add_destination(
1312                "destination",
1313                TestDestinationBuilder::with_input_type(EventType::EventD),
1314            )
1315            .expect("should not fail to add destination");
1316
1317        blueprint
1318    }
1319
1320    /// Builds a blueprint pre-populated with the given source and destination component IDs, all dealing in event-D
1321    /// events.
1322    ///
1323    /// No connections are made between the components.
1324    fn blueprint_with_sources_and_destinations(source_ids: &[&str], destination_ids: &[&str]) -> TopologyBlueprint {
1325        let component_registry = ComponentRegistry::default();
1326        let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1327
1328        for source_id in source_ids {
1329            blueprint
1330                .add_source(*source_id, TestSourceBuilder::default_output(EventType::EventD))
1331                .expect("should not fail to add source");
1332        }
1333
1334        for destination_id in destination_ids {
1335            blueprint
1336                .add_destination(
1337                    *destination_id,
1338                    TestDestinationBuilder::with_input_type(EventType::EventD),
1339                )
1340                .expect("should not fail to add destination");
1341        }
1342
1343        blueprint
1344    }
1345
1346    /// Collects the blueprint's directed connections as a sorted list of `(from, to)` component ID pairs.
1347    fn connected_pairs(blueprint: &TopologyBlueprint) -> Vec<(String, String)> {
1348        let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1349        let outbound_edges = guard
1350            .as_ref()
1351            .expect("topology blueprint already initialized")
1352            .graph
1353            .get_outbound_directed_edges();
1354
1355        let mut pairs = Vec::new();
1356        for (from, outputs) in &outbound_edges {
1357            for targets in outputs.values() {
1358                for to in targets {
1359                    pairs.push((from.component_id().to_string(), to.component_id().to_string()));
1360                }
1361            }
1362        }
1363        pairs.sort();
1364        pairs
1365    }
1366
1367    #[test]
1368    fn connect_components_in_order_errors_with_fewer_than_two_ids() {
1369        let mut blueprint = blueprint_with_components();
1370
1371        // No component IDs at all.
1372        let result = blueprint.connect_components_in_order(Vec::<&str>::new()).map(|_| ());
1373        assert!(result.is_err());
1374
1375        // A single component ID is still not enough to form a connection.
1376        let result = blueprint.connect_components_in_order(["source"]).map(|_| ());
1377        assert!(result.is_err());
1378
1379        // Neither attempt should have added any connections to the graph.
1380        assert!(connected_pairs(&blueprint).is_empty());
1381    }
1382
1383    #[test]
1384    fn connect_components_in_order_connects_pairwise_left_to_right() {
1385        let mut blueprint = blueprint_with_components();
1386
1387        blueprint
1388            .connect_components_in_order(["source", "transform", "destination"])
1389            .expect("should not fail to connect components in order");
1390
1391        // Adjacent components should be connected from left to right (`source` -> `transform` -> `destination`), with
1392        // a single edge shared between each pair.
1393        assert_eq!(
1394            connected_pairs(&blueprint),
1395            vec![
1396                ("source".to_string(), "transform".to_string()),
1397                ("transform".to_string(), "destination".to_string()),
1398            ],
1399        );
1400    }
1401
1402    #[test]
1403    fn connect_component_one_to_many_fans_out() {
1404        // A single upstream component is fanned out to multiple downstream components. The upstream ID is given as a
1405        // bare string (`Single`), while the downstream IDs are given as a slice (`Multiple`).
1406        let mut blueprint = blueprint_with_sources_and_destinations(&["source"], &["dest_a", "dest_b"]);
1407
1408        blueprint
1409            .connect_components("source", ["dest_a", "dest_b"])
1410            .expect("should not fail to connect component");
1411
1412        assert_eq!(
1413            connected_pairs(&blueprint),
1414            vec![
1415                ("source".to_string(), "dest_a".to_string()),
1416                ("source".to_string(), "dest_b".to_string()),
1417            ],
1418        );
1419    }
1420
1421    #[test]
1422    fn connect_component_many_to_one_fans_in() {
1423        // Multiple upstream components are fanned in to a single downstream component. The upstream IDs are given as a
1424        // slice (`Multiple`), while the downstream ID is given as a bare string (`Single`).
1425        let mut blueprint = blueprint_with_sources_and_destinations(&["source_a", "source_b"], &["dest"]);
1426
1427        blueprint
1428            .connect_components(["source_a", "source_b"], "dest")
1429            .expect("should not fail to connect component");
1430
1431        assert_eq!(
1432            connected_pairs(&blueprint),
1433            vec![
1434                ("source_a".to_string(), "dest".to_string()),
1435                ("source_b".to_string(), "dest".to_string()),
1436            ],
1437        );
1438    }
1439
1440    #[test]
1441    fn connect_component_many_to_many_creates_mesh() {
1442        // Multiple upstream components are meshed with multiple downstream components: every upstream component is
1443        // connected to every downstream component. Both sides are given as slices (`Multiple`).
1444        let mut blueprint = blueprint_with_sources_and_destinations(&["source_a", "source_b"], &["dest_a", "dest_b"]);
1445
1446        blueprint
1447            .connect_components(["source_a", "source_b"], ["dest_a", "dest_b"])
1448            .expect("should not fail to connect component");
1449
1450        assert_eq!(
1451            connected_pairs(&blueprint),
1452            vec![
1453                ("source_a".to_string(), "dest_a".to_string()),
1454                ("source_a".to_string(), "dest_b".to_string()),
1455                ("source_b".to_string(), "dest_a".to_string()),
1456                ("source_b".to_string(), "dest_b".to_string()),
1457            ],
1458        );
1459    }
1460
1461    /// Builds a connected `source` -> `transform` -> `destination` blueprint using the immediate-exit test components.
1462    fn connected_blueprint() -> TopologyBlueprint {
1463        let mut blueprint = blueprint_with_components();
1464        blueprint
1465            .connect_components_in_order(["source", "transform", "destination"])
1466            .expect("should not fail to connect components");
1467        blueprint
1468    }
1469
1470    #[tokio::test]
1471    async fn topology_failure_shuts_down_supervisor_without_a_restart() {
1472        // The test components all finish immediately, which the topology worker treats as an unexpected component
1473        // finish -- a topology failure. The supervisor needs the topology, so the failure must stop the supervisor (and,
1474        // in the real binary, exit the process).
1475        //
1476        // The default restart strategy allows a restart. But the first start of a topology consumes the build state of
1477        // the blueprint, and the blueprint reports that it cannot start again. Thus, the supervisor stops because of
1478        // the failure of the topology, and not because a second start failed to initialize.
1479        let mut blueprint = connected_blueprint();
1480        blueprint
1481            .with_health_registry(HealthRegistry::new())
1482            .with_memory_limiter(MemoryLimiter::noop())
1483            .with_resource_registry(ResourceRegistry::new());
1484        assert!(!blueprint.can_restart());
1485
1486        let result = run_blueprint_until_exit(blueprint, RestartStrategy::default()).await;
1487
1488        assert!(
1489            matches!(result, Err(SupervisorError::SignificantChildExited)),
1490            "the topology failure should stop the supervisor: {result:?}"
1491        );
1492    }
1493
1494    #[tokio::test]
1495    async fn topology_waits_for_environment_readiness_before_starting() {
1496        // The topology must not start its components until the environment readiness signal resolves. We provide a
1497        // signal that never resolves, then trigger shutdown: the topology should exit cleanly without ever spawning
1498        // its components (which would otherwise finish immediately and fail the supervisor), and the supervisor should
1499        // shut down successfully.
1500        // A readiness gate that records when the topology first reaches it, then never resolves. This lets us wait for
1501        // the topology to actually be blocked on readiness before shutting down, rather than sleeping.
1502        let gate_reached = Arc::new(AtomicUsize::new(0));
1503        let gate = {
1504            let gate_reached = Arc::clone(&gate_reached);
1505            async move {
1506                gate_reached.fetch_add(1, Ordering::SeqCst);
1507                std::future::pending::<()>().await;
1508            }
1509        };
1510
1511        let mut blueprint = connected_blueprint();
1512        blueprint
1513            .with_health_registry(HealthRegistry::new())
1514            .with_memory_limiter(MemoryLimiter::noop())
1515            .with_resource_registry(ResourceRegistry::new())
1516            .with_environment_readiness(gate);
1517
1518        let (tx, handle) = spawn_supervised_blueprint(blueprint);
1519
1520        // Once the topology is blocked on the readiness gate (and thus has NOT started its components), trigger
1521        // shutdown; it must exit cleanly.
1522        wait_until("the topology reached the readiness gate", || {
1523            gate_reached.load(Ordering::SeqCst) >= 1
1524        })
1525        .await;
1526        tx.send(()).expect("should send shutdown signal");
1527
1528        let result = join_topology(handle).await;
1529        assert!(result.is_ok(), "supervisor should shut down cleanly, got: {:?}", result);
1530    }
1531
1532    #[test]
1533    fn topology_ready_waits_for_registration_before_checking_readiness() {
1534        use tokio_test::{assert_pending, assert_ready, task::spawn};
1535
1536        let health_registry = HealthRegistry::new();
1537
1538        // Simulate an unrelated subsystem that has already registered and become ready. A naive readiness check against
1539        // the shared registry could resolve immediately here, even though the topology hasn't registered anything yet.
1540        let mut other = health_registry
1541            .register_component(&SubsystemIdentifier::from_dotted("env_provider.workload.foo"))
1542            .expect("should register component");
1543        other.mark_ready();
1544
1545        let (registered_tx, registered_rx) = oneshot::channel();
1546        let topology_ready = TopologyReady {
1547            registered_rx,
1548            health_registry: health_registry.clone(),
1549            component_root: topology_identifier("primary"),
1550        };
1551
1552        let mut wait = spawn(topology_ready.wait());
1553
1554        // Despite no topology components being registered yet, `wait` must not resolve: it's gated on the registration
1555        // signal, which is precisely what prevents a false-ready observation.
1556        assert_pending!(wait.poll());
1557
1558        // Now register a topology component (as the topology does when it spawns), but leave it not-ready.
1559        let mut source = health_registry
1560            .register_component(&SubsystemIdentifier::from_dotted("topology.primary.sources.in"))
1561            .expect("should register component");
1562
1563        // Fire the registration signal. `wait` advances to the scoped readiness check, which is still pending because
1564        // the topology component hasn't reported ready.
1565        registered_tx.send(()).expect("receiver should be alive");
1566        assert_pending!(wait.poll());
1567
1568        // Once the topology component reports ready, `wait` resolves to `true`.
1569        source.mark_ready();
1570        assert!(assert_ready!(wait.poll()));
1571    }
1572
1573    #[tokio::test]
1574    async fn topology_ready_returns_false_when_torn_down_before_registration() {
1575        let health_registry = HealthRegistry::new();
1576
1577        let (registered_tx, registered_rx) = oneshot::channel::<()>();
1578        let topology_ready = TopologyReady {
1579            registered_rx,
1580            health_registry,
1581            component_root: topology_identifier("primary"),
1582        };
1583
1584        // Drop the sender without ever signaling, as happens when the topology is torn down before it registers its
1585        // components. `wait` should report that readiness will never be reached.
1586        drop(registered_tx);
1587
1588        assert!(!topology_ready.wait().await);
1589    }
1590
1591    #[tokio::test]
1592    async fn topology_clean_shutdown_returns_ok() {
1593        // A source that runs until shutdown feeds a destination that drains until its upstream closes. On shutdown,
1594        // the source stops (it observes its supervisor's shutdown signal), the destination drains and stops, and the
1595        // topology supervisor returns cleanly -- which is the intentional-shutdown path, distinct from a component
1596        // unexpectedly finishing.
1597        let (blueprint, source_started) = long_running_blueprint(None);
1598        let (tx, handle) = spawn_supervised_blueprint(blueprint);
1599
1600        // Wait until the source is actually running before requesting shutdown, so we exercise shutting down live
1601        // components rather than shutting down before they start.
1602        wait_until("the source has started", || source_started.load(Ordering::SeqCst) == 1).await;
1603        tx.send(()).expect("should send shutdown signal");
1604
1605        let result = join_topology(handle).await;
1606        assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1607    }
1608
1609    #[tokio::test]
1610    async fn component_can_spawn_dynamic_child_via_spawn_handle() {
1611        // The source spawns a dynamic child through its context's spawn handle. The child records that it started and
1612        // then runs until torn down. We verify the child actually ran, then shut the topology down cleanly. The dynamic
1613        // child still runs at that time, and the clean shutdown stops it together with its component. Otherwise, the
1614        // drain does not complete, and the test times out.
1615        let child_started = Arc::new(AtomicUsize::new(0));
1616        let (blueprint, _source_started) = long_running_blueprint(Some(Arc::clone(&child_started)));
1617        let (tx, handle) = spawn_supervised_blueprint(blueprint);
1618
1619        // Wait until the dynamic child has started.
1620        wait_until("the dynamic child has started", || {
1621            child_started.load(Ordering::SeqCst) == 1
1622        })
1623        .await;
1624        assert_eq!(
1625            child_started.load(Ordering::SeqCst),
1626            1,
1627            "dynamic child should have started exactly once"
1628        );
1629
1630        tx.send(()).expect("should send shutdown signal");
1631        let result = join_topology(handle).await;
1632        assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1633    }
1634
1635    #[tokio::test]
1636    async fn each_component_runs_as_a_worker_named_by_its_canonical_identity() {
1637        // Components run directly under the topology supervisor, with no supervisor of their own. The process name of
1638        // each worker must be the canonical identity of its component. The health registry and resource accounting also
1639        // use this identity. The worker must also own that resource group, so that the component still has its own
1640        // figures.
1641        let (blueprint, source_started) = long_running_blueprint(None);
1642
1643        let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1644        let tree = supervisor.tree_handle();
1645        supervisor.add_worker(blueprint);
1646        let (tx, rx) = oneshot::channel::<()>();
1647        let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1648
1649        wait_until("the source has started", || source_started.load(Ordering::SeqCst) == 1).await;
1650
1651        fn find<'a>(node: &'a runtime::NodeSnapshot, name: &str) -> Option<&'a runtime::NodeSnapshot> {
1652            if node.name == name {
1653                return Some(node);
1654            }
1655            node.children.iter().find_map(|child| find(child, name))
1656        }
1657
1658        let root = tree.snapshot().root;
1659        let topology = find(&root, "topology.test").expect("the topology supervisor is in the tree");
1660        for (name, identity) in [
1661            ("sources.source", "topology.test.sources.source"),
1662            ("destinations.destination", "topology.test.destinations.destination"),
1663        ] {
1664            let component = topology
1665                .children
1666                .iter()
1667                .find(|child| child.name == name)
1668                .unwrap_or_else(|| panic!("{name} should run directly under the topology supervisor"));
1669            assert_eq!(component.kind, runtime::NodeKind::Worker);
1670            assert_eq!(component.process_name.as_deref(), Some(identity));
1671            assert_eq!(
1672                component.resource_group.as_deref(),
1673                Some(identity),
1674                "{name} should own its resource group"
1675            );
1676        }
1677
1678        tx.send(()).expect("should send shutdown signal");
1679        let result = join_topology(handle).await;
1680        assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1681    }
1682
1683    #[tokio::test]
1684    async fn work_spawned_while_a_component_is_built_belongs_to_the_component() {
1685        // The builder of a component runs inside the blueprint, before the process of the component exists. At that
1686        // time, the builder can spawn tasks into the current scope, for example the background tasks of a cache. The
1687        // scope holds these tasks until the component runs. Then the tasks belong to the component: they run beneath
1688        // the component, and stop with it.
1689        let source_started = Arc::new(AtomicUsize::new(0));
1690        let child_started = Arc::new(AtomicUsize::new(0));
1691        let child_stopped = Arc::new(AtomicUsize::new(0));
1692
1693        let component_registry = ComponentRegistry::default();
1694        let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1695        blueprint
1696            .add_source(
1697                "spawner",
1698                BuildSpawningSourceBuilder {
1699                    outputs: vec![OutputDefinition::default_output(EventType::EventD)],
1700                    source_started: Arc::clone(&source_started),
1701                    child_started: Arc::clone(&child_started),
1702                    child_stopped: Arc::clone(&child_stopped),
1703                },
1704            )
1705            .expect("should not fail to add source")
1706            .add_destination(
1707                "destination",
1708                DrainingDestinationBuilder {
1709                    input_event_ty: EventType::EventD,
1710                },
1711            )
1712            .expect("should not fail to add destination");
1713        blueprint
1714            .connect_components_in_order(["spawner", "destination"])
1715            .expect("should not fail to connect components");
1716        blueprint
1717            .with_health_registry(HealthRegistry::new())
1718            .with_memory_limiter(MemoryLimiter::noop())
1719            .with_resource_registry(ResourceRegistry::new());
1720
1721        let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1722        let tree = supervisor.tree_handle();
1723        supervisor.add_worker(blueprint);
1724        let (tx, rx) = oneshot::channel::<()>();
1725        let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1726
1727        fn find<'a>(node: &'a runtime::NodeSnapshot, name: &str) -> Option<&'a runtime::NodeSnapshot> {
1728            if node.name == name {
1729                return Some(node);
1730            }
1731            node.children.iter().find_map(|child| find(child, name))
1732        }
1733
1734        // The source worker gets its name from the component.
1735        wait_until("the child spawned while building runs beneath its component", || {
1736            let root = tree.snapshot().root;
1737            find(&root, "sources.spawner").is_some_and(|source| find(source, "built_child").is_some())
1738        })
1739        .await;
1740        assert_eq!(source_started.load(Ordering::SeqCst), 1);
1741        assert_eq!(child_started.load(Ordering::SeqCst), 1);
1742
1743        tx.send(()).expect("should send shutdown signal");
1744        let result = join_topology(handle).await;
1745        assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1746        assert_eq!(
1747            child_stopped.load(Ordering::SeqCst),
1748            1,
1749            "the child should have been stopped with its component"
1750        );
1751    }
1752
1753    #[tokio::test]
1754    async fn forced_abort_count_survives_topology_boundary_to_root() {
1755        // A component that ignores graceful shutdown is forcefully aborted, and that abort count must survive the
1756        // `TopologyBlueprint` boundary -- where the topology supervisor's `SupervisorError` is flattened into a
1757        // `GenericError` -- and be recovered by the root supervisor as `ShutdownTimedOut`. This exercises the
1758        // `downcast` recovery path in `reported_abort_count`, which the production topology depends on but the direct
1759        // nested-supervisor tests in `supervisor.rs` do not cover: those hit the structured `WorkerError` variant,
1760        // never the flattened-`GenericError` path taken here.
1761        //
1762        // The source stops cleanly on shutdown; the destination ignores shutdown and runs forever, so exactly one
1763        // component (the destination) is force-aborted after the (short) shutdown timeout.
1764        let (blueprint, destination_started) = stuck_destination_blueprint(Duration::from_millis(100));
1765        let (tx, handle) = spawn_supervised_blueprint(blueprint);
1766
1767        // The forced abort only happens if the stuck destination is actually running when shutdown arrives, so wait
1768        // for it to start before triggering shutdown.
1769        wait_until("the stuck destination has started", || {
1770            destination_started.load(Ordering::SeqCst) == 1
1771        })
1772        .await;
1773        tx.send(()).expect("should send shutdown signal");
1774
1775        let result = join_topology(handle).await;
1776        assert!(
1777            matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
1778            "a component that ignored shutdown must surface as an unclean shutdown with a count of 1, got {result:?}"
1779        );
1780    }
1781
1782    #[test]
1783    fn component_identity_is_byte_identical_across_subsystems() {
1784        // We want to ensure that when using a component's canonical identity, we are able to use that canonical
1785        // identical to reference the same entity across subsystems like the health registry, resource accounting, the
1786        // supervision/process tree, and so on.
1787
1788        for (topology_name, raw_id) in [("primary", "dsd_in"), ("secondary", "dsd_agg")] {
1789            // Calculate the root topology identifier, our component context, and the identifiers for the component.
1790            let topology_root = topology_identifier(topology_name);
1791            let component_context = ComponentContext::source(&topology_root, ComponentId::try_from(raw_id).unwrap());
1792            let component_canonical_id = component_context.identity().to_string();
1793            let component_relative_id =
1794                get_component_relative_identifier(component_context.component_type(), component_context.component_id());
1795
1796            assert_eq!(
1797                component_canonical_id,
1798                format!("topology.{topology_name}.sources.{raw_id}")
1799            );
1800
1801            // Resource accounting: the component is addressed by its canonical identity directly, so declaring bounds
1802            // for it must create a node whose full path -- segment by segment -- is exactly that canonical identity.
1803            let registry = ComponentRegistry::default();
1804            registry
1805                .bounds_builder(&component_context.identity())
1806                .firm()
1807                .with_fixed_amount("marker", 1);
1808
1809            let bounds = registry.as_bounds();
1810            let mut node = &bounds;
1811            for segment in component_canonical_id.split('.') {
1812                node = node
1813                    .subcomponents()
1814                    .into_iter()
1815                    .find_map(|(name, child)| (name.as_str() == segment).then_some(child))
1816                    .expect("resource-accounting node path must match the canonical identity segment by segment");
1817            }
1818            assert_eq!(
1819                node.total_firm_limit_bytes(),
1820                1,
1821                "the resource-accounting node at the canonical path must hold the declared bounds"
1822            );
1823
1824            // Supervision: when using relative subsystem identifiers in a nested fashion, we should end up with
1825            // a process name that matches the canonical identity.
1826            let topology_sup = Name::root(topology_root.to_string()).expect("topology name is non-empty");
1827            let process_name =
1828                Name::scoped(&topology_sup, component_relative_id.to_string()).expect("component name is non-empty");
1829            assert_eq!(
1830                &*process_name,
1831                component_canonical_id.as_str(),
1832                "component worker process name must match the canonical identity"
1833            );
1834        }
1835    }
1836
1837    #[test]
1838    fn recalculate_bounds_accounts_for_interconnect_and_event_buffer_memory() {
1839        // `recalculate_bounds` sizes the topology's interconnect and event-buffer memory from the component counts and
1840        // the interconnect capacity. Build a source -> transform -> destination topology, then set a distinctive
1841        // interconnect capacity so the recalculation runs with all components present, and assert the exact byte
1842        // totals against the documented arithmetic.
1843        let interconnect_capacity = 4usize;
1844        let mut blueprint = blueprint_with_components();
1845        blueprint.with_interconnect_capacity(NonZeroUsize::new(interconnect_capacity).unwrap());
1846
1847        // Component counts once all three components are registered.
1848        let (sources, transforms, destinations, decoders) = (1usize, 1usize, 1usize, 0usize);
1849
1850        // Minimum: one preallocated interconnect (holding `capacity` event buffers) per non-source component (that is,
1851        // every transform and destination).
1852        let total_interconnect_capacity = interconnect_capacity * (transforms + destinations);
1853        let expected_min = total_interconnect_capacity * std::mem::size_of::<EventsBuffer>();
1854
1855        // Firm: the maximum number of in-flight event buffers, each sized as one events-buffer container plus the
1856        // events it holds at the default per-buffer capacity. The firm total is additive with the minimum.
1857        let max_in_flight = ((transforms + destinations) * interconnect_capacity) + sources + decoders + transforms;
1858        let per_buffer =
1859            std::mem::size_of::<EventsBuffer>() + (std::mem::size_of::<Event>() * DEFAULT_EVENTS_BUFFER_CAPACITY);
1860        let expected_firm = expected_min + (max_in_flight * per_buffer);
1861
1862        let bounds = {
1863            let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1864            guard
1865                .as_ref()
1866                .expect("topology blueprint already initialized")
1867                .component_registry
1868                .as_bounds()
1869        };
1870
1871        assert_eq!(
1872            bounds.total_minimum_required_bytes(),
1873            expected_min,
1874            "interconnect minimum bytes should be capacity * non-source components * size_of::<EventsBuffer>()"
1875        );
1876        assert_eq!(
1877            bounds.total_firm_limit_bytes(),
1878            expected_firm,
1879            "firm bytes should add the max in-flight event-buffer memory on top of the minimum"
1880        );
1881    }
1882
1883    #[test]
1884    fn worker_pool_configuration_defaults_to_dedicated() {
1885        let blueprint = blueprint_with_components();
1886        let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1887        let config = &guard
1888            .as_ref()
1889            .expect("topology blueprint already initialized")
1890            .worker_pool_config;
1891        assert!(
1892            matches!(config, WorkerPoolConfiguration::Dedicated),
1893            "the default worker-pool configuration must be dedicated"
1894        );
1895    }
1896
1897    #[test]
1898    fn with_ambient_worker_pool_selects_ambient() {
1899        let mut blueprint = blueprint_with_components();
1900        blueprint.with_ambient_worker_pool();
1901
1902        let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1903        let config = &guard
1904            .as_ref()
1905            .expect("topology blueprint already initialized")
1906            .worker_pool_config;
1907        assert!(matches!(config, WorkerPoolConfiguration::Ambient));
1908    }
1909
1910    #[tokio::test]
1911    async fn with_explicit_worker_pool_selects_explicit() {
1912        let mut blueprint = blueprint_with_components();
1913        blueprint.with_explicit_worker_pool(tokio::runtime::Handle::current());
1914
1915        let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1916        let config = &guard
1917            .as_ref()
1918            .expect("topology blueprint already initialized")
1919            .worker_pool_config;
1920        assert!(matches!(config, WorkerPoolConfiguration::Explicit(_)));
1921    }
1922}