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