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