1use std::{collections::HashMap, future::Future, num::NonZeroUsize, pin::Pin, sync::Mutex, time::Duration};
2
3use async_trait::async_trait;
4use saluki_common::resource_tracking::Track as _;
5use saluki_common::sync::shutdown::ShutdownHandle;
6use saluki_error::{generic_error, ErrorContext as _, GenericError};
7use snafu::Snafu;
8use tokio::{pin, runtime::Handle, select, sync::oneshot};
9use tracing::info;
10
11use super::{
12 built::{BuiltTopology, WorkerPoolConfiguration},
13 graph::{Graph, GraphError},
14 ComponentId,
15};
16use crate::accounting::{ComponentRegistry, MemoryLimiter, UsageExpr};
17use crate::{
18 components::{
19 decoders::DecoderBuilder, destinations::DestinationBuilder, encoders::EncoderBuilder,
20 forwarders::ForwarderBuilder, relays::RelayBuilder, sources::SourceBuilder, transforms::TransformBuilder,
21 BuildContext, ComponentContext, ComponentType,
22 },
23 data_model::event::Event,
24 health::HealthRegistry,
25 runtime::{
26 halt::HaltWatch,
27 state::{DataspaceRegistry, ResourceRegistry},
28 InitializationError, ShutdownStrategy, Supervisable, SupervisorFuture, UnadoptedScope,
29 },
30 support::SubsystemIdentifier,
31 topology::{ids::AsComponentIds, topology_identifier, EventsBuffer, DEFAULT_EVENTS_BUFFER_CAPACITY},
32};
33
34const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
35
36#[derive(Debug, Snafu)]
38#[snafu(context(suffix(false)))]
39pub enum BlueprintError {
40 #[snafu(display("Failed to build/validate topology graph: {}", source))]
42 InvalidGraph {
43 source: GraphError,
45 },
46
47 #[snafu(display("Failed to build component '{}': {}", id, source))]
49 FailedToBuildComponent {
50 id: ComponentId,
52
53 source: GenericError,
55 },
56}
57
58pub struct TopologyBlueprint {
64 name: String,
65 build_state: Mutex<Option<TopologyBuildState>>,
66 health_registry: Option<HealthRegistry>,
67 memory_limiter: Option<MemoryLimiter>,
68 resource_registry: Option<ResourceRegistry>,
69}
70
71struct TopologyBuildState {
75 topology_id: SubsystemIdentifier,
76 graph: Graph,
77 sources: HashMap<ComponentId, Box<dyn SourceBuilder + Send>>,
78 relays: HashMap<ComponentId, Box<dyn RelayBuilder + Send>>,
79 decoders: HashMap<ComponentId, Box<dyn DecoderBuilder + Send>>,
80 transforms: HashMap<ComponentId, Box<dyn TransformBuilder + Send>>,
81 destinations: HashMap<ComponentId, Box<dyn DestinationBuilder + Send>>,
82 encoders: HashMap<ComponentId, Box<dyn EncoderBuilder + Send>>,
83 forwarders: HashMap<ComponentId, Box<dyn ForwarderBuilder + Send>>,
84 component_registry: ComponentRegistry,
85 interconnect_capacity: NonZeroUsize,
86 shutdown_timeout: Duration,
87 worker_pool_config: WorkerPoolConfiguration,
88 environment_ready: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
89 ready_signal: Option<oneshot::Sender<()>>,
90}
91
92impl TopologyBlueprint {
93 pub fn new(name: &str, component_registry: &ComponentRegistry) -> Self {
95 let topology_id = topology_identifier(name);
96 let component_registry = component_registry.clone();
97
98 let build_state = TopologyBuildState {
99 topology_id,
100 graph: Graph::default(),
101 sources: HashMap::new(),
102 relays: HashMap::new(),
103 decoders: HashMap::new(),
104 transforms: HashMap::new(),
105 destinations: HashMap::new(),
106 encoders: HashMap::new(),
107 forwarders: HashMap::new(),
108 component_registry,
109 interconnect_capacity: super::DEFAULT_INTERCONNECT_CAPACITY,
110 shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
111 worker_pool_config: WorkerPoolConfiguration::Dedicated,
112 environment_ready: None,
113 ready_signal: None,
114 };
115
116 Self {
117 name: name.to_string(),
118 build_state: Mutex::new(Some(build_state)),
119 health_registry: None,
120 memory_limiter: None,
121 resource_registry: None,
122 }
123 }
124
125 fn state_mut(&mut self) -> &mut TopologyBuildState {
131 self.build_state
132 .get_mut()
133 .expect("topology blueprint mutex poisoned")
134 .as_mut()
135 .expect("topology blueprint already initialized")
136 }
137
138 pub fn with_interconnect_capacity(&mut self, capacity: NonZeroUsize) -> &mut Self {
146 self.state_mut().set_interconnect_capacity(capacity);
147 self
148 }
149
150 pub fn with_shutdown_timeout(&mut self, timeout: Duration) -> &mut Self {
154 self.state_mut().shutdown_timeout = timeout;
155 self
156 }
157
158 pub fn with_health_registry(&mut self, health_registry: HealthRegistry) -> &mut Self {
162 self.health_registry = Some(health_registry);
163 self
164 }
165
166 pub fn with_memory_limiter(&mut self, memory_limiter: MemoryLimiter) -> &mut Self {
170 self.memory_limiter = Some(memory_limiter);
171 self
172 }
173
174 pub fn with_resource_registry(&mut self, resource_registry: ResourceRegistry) -> &mut Self {
182 self.resource_registry = Some(resource_registry);
183 self
184 }
185
186 pub fn with_environment_readiness<F>(&mut self, ready: F) -> &mut Self
192 where
193 F: Future<Output = ()> + Send + 'static,
194 {
195 self.state_mut().environment_ready = Some(Box::pin(ready));
196 self
197 }
198
199 pub fn topology_ready(&mut self) -> TopologyReady {
208 let health_registry = self
209 .health_registry
210 .clone()
211 .expect("health registry must be set before acquiring a topology readiness handle");
212 let component_root = super::topology_identifier(&self.name);
213
214 let (registered_tx, registered_rx) = oneshot::channel();
215 self.state_mut().ready_signal = Some(registered_tx);
216
217 TopologyReady {
218 registered_rx,
219 health_registry,
220 component_root,
221 }
222 }
223
224 pub fn with_ambient_worker_pool(&mut self) -> &mut Self {
229 self.state_mut().worker_pool_config = WorkerPoolConfiguration::Ambient;
230 self
231 }
232
233 pub fn with_explicit_worker_pool(&mut self, handle: Handle) -> &mut Self {
237 self.state_mut().worker_pool_config = WorkerPoolConfiguration::Explicit(handle);
238 self
239 }
240
241 pub fn add_source<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
247 where
248 I: AsRef<str>,
249 B: SourceBuilder + Send + 'static,
250 {
251 self.state_mut().add_source(component_id, builder)?;
252 Ok(self)
253 }
254
255 pub fn add_relay<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
261 where
262 I: AsRef<str>,
263 B: RelayBuilder + Send + 'static,
264 {
265 self.state_mut().add_relay(component_id, builder)?;
266 Ok(self)
267 }
268
269 pub fn add_decoder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
275 where
276 I: AsRef<str>,
277 B: DecoderBuilder + Send + 'static,
278 {
279 self.state_mut().add_decoder(component_id, builder)?;
280 Ok(self)
281 }
282
283 pub fn add_transform<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
289 where
290 I: AsRef<str>,
291 B: TransformBuilder + Send + 'static,
292 {
293 self.state_mut().add_transform(component_id, builder)?;
294 Ok(self)
295 }
296
297 pub fn add_destination<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
303 where
304 I: AsRef<str>,
305 B: DestinationBuilder + Send + 'static,
306 {
307 self.state_mut().add_destination(component_id, builder)?;
308 Ok(self)
309 }
310
311 pub fn add_encoder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
317 where
318 I: AsRef<str>,
319 B: EncoderBuilder + Send + 'static,
320 {
321 self.state_mut().add_encoder(component_id, builder)?;
322 Ok(self)
323 }
324
325 pub fn add_forwarder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
331 where
332 I: AsRef<str>,
333 B: ForwarderBuilder + Send + 'static,
334 {
335 self.state_mut().add_forwarder(component_id, builder)?;
336 Ok(self)
337 }
338
339 pub fn connect_components<MS, SI, MD, DI>(
354 &mut self, upstream_output_component_ids: SI, downstream_component_ids: DI,
355 ) -> Result<&mut Self, GenericError>
356 where
357 SI: AsComponentIds<MS>,
358 DI: AsComponentIds<MD>,
359 {
360 self.state_mut()
361 .connect_components(upstream_output_component_ids, downstream_component_ids)?;
362 Ok(self)
363 }
364
365 pub fn connect_components_in_order<IT, I>(&mut self, ordered_component_ids: IT) -> Result<&mut Self, GenericError>
387 where
388 IT: IntoIterator<Item = I>,
389 I: AsRef<str>,
390 {
391 self.state_mut().connect_components_in_order(ordered_component_ids)?;
392 Ok(self)
393 }
394}
395
396pub struct TopologyReady {
398 registered_rx: oneshot::Receiver<()>,
399 health_registry: HealthRegistry,
400 component_root: SubsystemIdentifier,
401}
402
403impl TopologyReady {
404 pub async fn wait(self) -> bool {
410 if self.registered_rx.await.is_err() {
415 return false;
416 }
417
418 self.health_registry.all_ready_under(self.component_root).await;
420
421 true
422 }
423}
424
425impl TopologyBuildState {
426 fn set_interconnect_capacity(&mut self, capacity: NonZeroUsize) {
427 self.interconnect_capacity = capacity;
428 self.recalculate_bounds();
429 }
430
431 fn recalculate_bounds(&mut self) {
432 let interconnect_capacity = self.interconnect_capacity.get();
433
434 let mut bounds_builder = self.component_registry.bounds_builder(&self.topology_id);
435 let mut bounds_builder = bounds_builder.subcomponent("interconnects");
436 bounds_builder.reset();
437
438 let total_interconnect_capacity = interconnect_capacity * (self.transforms.len() + self.destinations.len());
443 bounds_builder
444 .minimum()
445 .with_array::<EventsBuffer>("events", total_interconnect_capacity);
446
447 let max_in_flight_event_buffers = ((self.transforms.len() + self.destinations.len()) * interconnect_capacity)
458 + self.sources.len()
459 + self.decoders.len()
460 + self.transforms.len();
461
462 bounds_builder
463 .firm()
464 .with_expr(UsageExpr::product(
466 "events",
467 UsageExpr::constant("max in-flight event buffers", max_in_flight_event_buffers),
468 UsageExpr::sum(
469 "",
470 UsageExpr::struct_size::<EventsBuffer>("events buffer"),
471 UsageExpr::product(
472 "",
473 UsageExpr::struct_size::<Event>("event"),
474 UsageExpr::constant("default event buffer capacity", DEFAULT_EVENTS_BUFFER_CAPACITY),
475 ),
476 ),
477 ));
478 }
479
480 fn component_identity(&self, component_type: ComponentType, component_id: &ComponentId) -> SubsystemIdentifier {
481 ComponentContext::new(&self.topology_id, component_id.clone(), component_type).identity()
482 }
483
484 fn add_source<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
485 where
486 I: AsRef<str>,
487 B: SourceBuilder + Send + 'static,
488 {
489 let component_id = self
490 .graph
491 .add_source(component_id, &builder)
492 .error_context("Failed to add source to topology graph.")?;
493
494 let identity = self.component_identity(ComponentType::Source, &component_id);
495 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
496
497 self.recalculate_bounds();
498
499 let _ = self.sources.insert(component_id, Box::new(builder));
500
501 Ok(())
502 }
503
504 fn add_relay<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
505 where
506 I: AsRef<str>,
507 B: RelayBuilder + Send + 'static,
508 {
509 let component_id = self
510 .graph
511 .add_relay(component_id, &builder)
512 .error_context("Failed to add relay to topology graph.")?;
513
514 let identity = self.component_identity(ComponentType::Relay, &component_id);
515 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
516
517 self.recalculate_bounds();
518
519 let _ = self.relays.insert(component_id, Box::new(builder));
520
521 Ok(())
522 }
523
524 fn add_decoder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
525 where
526 I: AsRef<str>,
527 B: DecoderBuilder + Send + 'static,
528 {
529 let component_id = self
530 .graph
531 .add_decoder(component_id, &builder)
532 .error_context("Failed to add decoder to topology graph.")?;
533
534 let identity = self.component_identity(ComponentType::Decoder, &component_id);
535 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
536
537 self.recalculate_bounds();
538
539 let _ = self.decoders.insert(component_id, Box::new(builder));
540
541 Ok(())
542 }
543
544 fn add_transform<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
545 where
546 I: AsRef<str>,
547 B: TransformBuilder + Send + 'static,
548 {
549 let component_id = self
550 .graph
551 .add_transform(component_id, &builder)
552 .error_context("Failed to add transform to topology graph.")?;
553
554 let identity = self.component_identity(ComponentType::Transform, &component_id);
555 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
556
557 self.recalculate_bounds();
558
559 let _ = self.transforms.insert(component_id, Box::new(builder));
560
561 Ok(())
562 }
563
564 fn add_destination<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
565 where
566 I: AsRef<str>,
567 B: DestinationBuilder + Send + 'static,
568 {
569 let component_id = self
570 .graph
571 .add_destination(component_id, &builder)
572 .error_context("Failed to add destination to topology graph.")?;
573
574 let identity = self.component_identity(ComponentType::Destination, &component_id);
575 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
576
577 self.recalculate_bounds();
578
579 let _ = self.destinations.insert(component_id, Box::new(builder));
580
581 Ok(())
582 }
583
584 fn add_encoder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
585 where
586 I: AsRef<str>,
587 B: EncoderBuilder + Send + 'static,
588 {
589 let component_id = self
590 .graph
591 .add_encoder(component_id, &builder)
592 .error_context("Failed to add encoder to topology graph.")?;
593
594 let identity = self.component_identity(ComponentType::Encoder, &component_id);
595 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
596
597 self.recalculate_bounds();
598
599 let _ = self.encoders.insert(component_id, Box::new(builder));
600
601 Ok(())
602 }
603
604 fn add_forwarder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
605 where
606 I: AsRef<str>,
607 B: ForwarderBuilder + Send + 'static,
608 {
609 let component_id = self
610 .graph
611 .add_forwarder(component_id, &builder)
612 .error_context("Failed to add forwarder to topology graph.")?;
613
614 let identity = self.component_identity(ComponentType::Forwarder, &component_id);
615 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
616
617 self.recalculate_bounds();
618
619 let _ = self.forwarders.insert(component_id, Box::new(builder));
620
621 Ok(())
622 }
623
624 fn connect_components<MS, SI, MD, DI>(
625 &mut self, upstream_output_component_ids: SI, downstream_component_ids: DI,
626 ) -> Result<(), GenericError>
627 where
628 SI: AsComponentIds<MS>,
629 DI: AsComponentIds<MD>,
630 {
631 for upstream_output_component_id in upstream_output_component_ids.as_component_ids() {
632 for downstream_component_id in downstream_component_ids.as_component_ids() {
633 self.graph
634 .add_edge(upstream_output_component_id.as_ref(), downstream_component_id.as_ref())
635 .error_context("Failed to add component connection to topology graph.")?;
636 }
637 }
638
639 Ok(())
640 }
641
642 fn connect_components_in_order<IT, I>(&mut self, ordered_component_ids: IT) -> Result<(), GenericError>
643 where
644 IT: IntoIterator<Item = I>,
645 I: AsRef<str>,
646 {
647 let mut pending_output_component_id: Option<I> = None;
648 let mut connected_any = false;
649
650 for component_id in ordered_component_ids.into_iter() {
651 if let Some(output_component_id) = pending_output_component_id.take() {
652 self.graph
653 .add_edge(output_component_id.as_ref(), component_id.as_ref())
654 .error_context("Failed to add component connection to topology graph.")?;
655
656 connected_any = true;
657 }
658
659 pending_output_component_id = Some(component_id);
661 }
662
663 if !connected_any {
665 return Err(generic_error!(
666 "Two or more components must be provided for connection."
667 ));
668 }
669
670 Ok(())
671 }
672
673 async fn build(self, name: String, resource_registry: &ResourceRegistry) -> Result<BuiltTopology, GenericError> {
679 self.graph.validate().error_context("Failed to build topology graph.")?;
680
681 let mut component_scopes = HashMap::new();
682
683 let mut sources = HashMap::new();
684 for (id, builder) in self.sources {
685 let component_context = ComponentContext::source(&self.topology_id, id.clone());
686 let allocation_token = self
687 .component_registry
688 .get_resource_group_token(&component_context.identity());
689 let scope = UnadoptedScope::new(component_context.identity().to_string());
693 let source = scope
694 .scope()
695 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
696 .track_resources(allocation_token)
697 .await
698 .with_error_context(|| format!("Failed to build source '{}'.", id))?;
699
700 component_scopes.insert(component_context.clone(), scope);
701 sources.insert(component_context, (source, self.component_registry.clone()));
702 }
703
704 let mut relays = HashMap::new();
705 for (id, builder) in self.relays {
706 let component_context = ComponentContext::relay(&self.topology_id, id.clone());
707 let allocation_token = self
708 .component_registry
709 .get_resource_group_token(&component_context.identity());
710 let scope = UnadoptedScope::new(component_context.identity().to_string());
714 let relay = scope
715 .scope()
716 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
717 .track_resources(allocation_token)
718 .await
719 .with_error_context(|| format!("Failed to build relay '{}'.", id))?;
720
721 component_scopes.insert(component_context.clone(), scope);
722 relays.insert(component_context, (relay, self.component_registry.clone()));
723 }
724
725 let mut decoders = HashMap::new();
726 for (id, builder) in self.decoders {
727 let component_context = ComponentContext::decoder(&self.topology_id, id.clone());
728 let allocation_token = self
729 .component_registry
730 .get_resource_group_token(&component_context.identity());
731 let scope = UnadoptedScope::new(component_context.identity().to_string());
735 let decoder = scope
736 .scope()
737 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
738 .track_resources(allocation_token)
739 .await
740 .with_error_context(|| format!("Failed to build decoder '{}'.", id))?;
741
742 component_scopes.insert(component_context.clone(), scope);
743 decoders.insert(component_context, (decoder, self.component_registry.clone()));
744 }
745
746 let mut transforms = HashMap::new();
747 for (id, builder) in self.transforms {
748 let component_context = ComponentContext::transform(&self.topology_id, id.clone());
749 let allocation_token = self
750 .component_registry
751 .get_resource_group_token(&component_context.identity());
752 let scope = UnadoptedScope::new(component_context.identity().to_string());
756 let transform = scope
757 .scope()
758 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
759 .track_resources(allocation_token)
760 .await
761 .with_error_context(|| format!("Failed to build transform '{}'.", id))?;
762
763 component_scopes.insert(component_context.clone(), scope);
764 transforms.insert(component_context, (transform, self.component_registry.clone()));
765 }
766
767 let mut destinations = HashMap::new();
768 for (id, builder) in self.destinations {
769 let component_context = ComponentContext::destination(&self.topology_id, id.clone());
770 let allocation_token = self
771 .component_registry
772 .get_resource_group_token(&component_context.identity());
773 let scope = UnadoptedScope::new(component_context.identity().to_string());
777 let destination = scope
778 .scope()
779 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
780 .track_resources(allocation_token)
781 .await
782 .with_error_context(|| format!("Failed to build destination '{}'.", id))?;
783
784 component_scopes.insert(component_context.clone(), scope);
785 destinations.insert(component_context, (destination, self.component_registry.clone()));
786 }
787
788 let mut encoders = HashMap::new();
789 for (id, builder) in self.encoders {
790 let component_context = ComponentContext::encoder(&self.topology_id, id.clone());
791 let allocation_token = self
792 .component_registry
793 .get_resource_group_token(&component_context.identity());
794 let scope = UnadoptedScope::new(component_context.identity().to_string());
798 let encoder = scope
799 .scope()
800 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
801 .track_resources(allocation_token)
802 .await
803 .with_error_context(|| format!("Failed to build encoder '{}'.", id))?;
804
805 component_scopes.insert(component_context.clone(), scope);
806 encoders.insert(component_context, (encoder, self.component_registry.clone()));
807 }
808
809 let mut forwarders = HashMap::new();
810 for (id, builder) in self.forwarders {
811 let component_context = ComponentContext::forwarder(&self.topology_id, id.clone());
812 let allocation_token = self
813 .component_registry
814 .get_resource_group_token(&component_context.identity());
815 let scope = UnadoptedScope::new(component_context.identity().to_string());
819 let forwarder = scope
820 .scope()
821 .enter(builder.build(BuildContext::new(component_context.clone(), resource_registry.clone())))
822 .track_resources(allocation_token)
823 .await
824 .with_error_context(|| format!("Failed to build forwarder '{}'.", id))?;
825
826 component_scopes.insert(component_context.clone(), scope);
827 forwarders.insert(component_context, (forwarder, self.component_registry.clone()));
828 }
829
830 let topology_token = self.component_registry.get_resource_group_token(&self.topology_id);
831
832 Ok(BuiltTopology::from_parts(
833 name,
834 self.topology_id,
835 self.graph,
836 sources,
837 relays,
838 decoders,
839 transforms,
840 destinations,
841 encoders,
842 forwarders,
843 component_scopes,
844 topology_token,
845 self.interconnect_capacity,
846 self.worker_pool_config,
847 ))
848 }
849}
850
851#[async_trait]
852impl Supervisable for TopologyBlueprint {
853 fn name(&self) -> &str {
854 &self.name
855 }
856
857 fn shutdown_strategy(&self) -> ShutdownStrategy {
858 ShutdownStrategy::Graceful(Duration::MAX)
860 }
861
862 fn can_restart(&self) -> bool {
863 false
865 }
866
867 async fn initialize(&self, shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
868 let mut build_state = self
872 .build_state
873 .lock()
874 .expect("topology blueprint mutex poisoned")
875 .take()
876 .ok_or_else(|| generic_error!("Topology has already been initialized and cannot be run more than once."))?;
877
878 let health_registry = self
879 .health_registry
880 .clone()
881 .ok_or_else(|| generic_error!("Topology blueprint is missing its health registry."))?;
882 let memory_limiter = self
883 .memory_limiter
884 .clone()
885 .ok_or_else(|| generic_error!("Topology blueprint is missing its memory limiter."))?;
886
887 let resource_registry = self
888 .resource_registry
889 .clone()
890 .ok_or_else(|| generic_error!("Topology blueprint is missing its resource registry."))?;
891
892 let dataspace = DataspaceRegistry::try_current()
893 .ok_or_else(|| generic_error!("Topology must be initialized within a supervised process context."))?;
894
895 let environment_ready = build_state.environment_ready.take();
903 let ready_signal = build_state.ready_signal.take();
904 let shutdown_timeout = build_state.shutdown_timeout;
905 let built = build_state.build(self.name.clone(), &resource_registry).await?;
906
907 Ok(Box::pin(async move {
908 pin!(shutdown);
909
910 if let Some(environment_ready) = environment_ready {
913 select! {
914 _ = &mut shutdown => return Ok(()),
915 _ = environment_ready => {},
916 }
917 }
918
919 let mut topology_sup = built
922 .spawn_inner(&health_registry, memory_limiter, dataspace.clone(), shutdown_timeout)
923 .await?;
924
925 if let Some(ready_signal) = ready_signal {
929 let _ = ready_signal.send(());
930 }
931
932 let (topology_shutdown_trigger, topology_shutdown) = ShutdownHandle::paired();
943 let run = topology_sup.run_with_shutdown_inner(topology_shutdown, HaltWatch::never(), Some(dataspace));
944 pin!(run);
945
946 let mut topology_shutdown_trigger = Some(topology_shutdown_trigger);
947 loop {
948 select! {
949 result = &mut run => return result.map_err(Into::into),
950 _ = &mut shutdown, if topology_shutdown_trigger.is_some() => {
951 info!("Topology received shutdown signal. Shutting down...");
952 topology_shutdown_trigger.take().expect("present per select guard").shutdown();
953 }
954 }
955 }
956 }))
957 }
958}
959
960#[cfg(test)]
961mod tests {
962 use std::num::NonZeroUsize;
963 use std::sync::atomic::{AtomicUsize, Ordering};
964 use std::sync::Arc;
965 use std::time::Duration;
966
967 use async_trait::async_trait;
968 use saluki_common::sync::shutdown::ShutdownHandle;
969 use saluki_error::GenericError;
970 use tokio::sync::oneshot;
971
972 use super::{TopologyBlueprint, TopologyReady, WorkerPoolConfiguration};
973 use crate::accounting::{ComponentRegistry, MemoryBounds, MemoryBoundsBuilder, MemoryLimiter};
974 use crate::components::BuildContext;
975 use crate::data_model::event::Event;
976 use crate::runtime::{self, state::ResourceRegistry, Name};
977 use crate::test_support::wait_until;
978 use crate::topology::{ids::get_component_relative_identifier, topology_identifier, ComponentId};
979 use crate::topology::{EventsBuffer, DEFAULT_EVENTS_BUFFER_CAPACITY};
980 use crate::{
981 components::{
982 destinations::{Destination, DestinationBuilder, DestinationContext},
983 sources::{Source, SourceBuilder, SourceContext},
984 ComponentContext,
985 },
986 data_model::event::EventType,
987 health::HealthRegistry,
988 runtime::{InitializationError, RestartStrategy, Supervisable, Supervisor, SupervisorError, SupervisorFuture},
989 support::SubsystemIdentifier,
990 topology::{
991 test_util::{TestDestinationBuilder, TestSourceBuilder, TestTransformBuilder},
992 OutputDefinition,
993 },
994 };
995
996 struct CountingChild {
1000 started: Arc<AtomicUsize>,
1001 }
1002
1003 #[async_trait]
1004 impl Supervisable for CountingChild {
1005 fn name(&self) -> &str {
1006 "child"
1007 }
1008
1009 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
1010 let started = Arc::clone(&self.started);
1011 Ok(Box::pin(async move {
1012 started.fetch_add(1, Ordering::SeqCst);
1013 process_shutdown.await;
1015 Ok(())
1016 }))
1017 }
1018 }
1019
1020 struct ControlSource {
1024 started: Arc<AtomicUsize>,
1025 spawned_child: Option<Arc<AtomicUsize>>,
1026 }
1027
1028 #[async_trait]
1029 impl Source for ControlSource {
1030 async fn run(self: Box<Self>, mut context: SourceContext) -> Result<(), GenericError> {
1031 self.started.fetch_add(1, Ordering::SeqCst);
1032 let shutdown = context.take_shutdown_handle();
1033 if let Some(started) = self.spawned_child {
1034 runtime::supervisable(CountingChild { started }).spawn();
1037 }
1038 shutdown.await;
1039 Ok(())
1040 }
1041 }
1042
1043 struct ControlSourceBuilder {
1044 outputs: Vec<OutputDefinition<EventType>>,
1045 started: Arc<AtomicUsize>,
1046 spawned_child: Option<Arc<AtomicUsize>>,
1047 }
1048
1049 impl ControlSourceBuilder {
1050 fn new(started: Arc<AtomicUsize>, spawned_child: Option<Arc<AtomicUsize>>) -> Self {
1051 Self {
1052 outputs: vec![OutputDefinition::default_output(EventType::EventD)],
1053 started,
1054 spawned_child,
1055 }
1056 }
1057 }
1058
1059 #[async_trait]
1060 impl SourceBuilder for ControlSourceBuilder {
1061 fn outputs(&self) -> &[OutputDefinition<EventType>] {
1062 &self.outputs
1063 }
1064
1065 async fn build(&self, _: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
1066 Ok(Box::new(ControlSource {
1067 started: Arc::clone(&self.started),
1068 spawned_child: self.spawned_child.clone(),
1069 }))
1070 }
1071 }
1072
1073 impl MemoryBounds for ControlSourceBuilder {
1074 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1075 }
1076
1077 struct BuildSpawningSourceBuilder {
1081 outputs: Vec<OutputDefinition<EventType>>,
1082 source_started: Arc<AtomicUsize>,
1083 child_started: Arc<AtomicUsize>,
1084 child_stopped: Arc<AtomicUsize>,
1085 }
1086
1087 #[async_trait]
1088 impl SourceBuilder for BuildSpawningSourceBuilder {
1089 fn outputs(&self) -> &[OutputDefinition<EventType>] {
1090 &self.outputs
1091 }
1092
1093 async fn build(&self, _: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
1094 let (started, stopped) = (Arc::clone(&self.child_started), Arc::clone(&self.child_stopped));
1095 runtime::worker_with_shutdown("built_child", move |shutdown| async move {
1096 started.fetch_add(1, Ordering::SeqCst);
1097 shutdown.await;
1098 stopped.fetch_add(1, Ordering::SeqCst);
1099 })
1100 .spawn_or_detached();
1101
1102 Ok(Box::new(ControlSource {
1103 started: Arc::clone(&self.source_started),
1104 spawned_child: None,
1105 }))
1106 }
1107 }
1108
1109 impl MemoryBounds for BuildSpawningSourceBuilder {
1110 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1111 }
1112
1113 struct DrainingDestination;
1115
1116 #[async_trait]
1117 impl Destination for DrainingDestination {
1118 async fn run(self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
1119 while context.events().next().await.is_some() {}
1120 Ok(())
1121 }
1122 }
1123
1124 struct DrainingDestinationBuilder {
1125 input_event_ty: EventType,
1126 }
1127
1128 #[async_trait]
1129 impl DestinationBuilder for DrainingDestinationBuilder {
1130 fn input_event_type(&self) -> EventType {
1131 self.input_event_ty
1132 }
1133
1134 async fn build(&self, _: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1135 Ok(Box::new(DrainingDestination))
1136 }
1137 }
1138
1139 impl MemoryBounds for DrainingDestinationBuilder {
1140 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1141 }
1142
1143 struct StuckDestination {
1147 started: Arc<AtomicUsize>,
1148 }
1149
1150 #[async_trait]
1151 impl Destination for StuckDestination {
1152 async fn run(self: Box<Self>, _context: DestinationContext) -> Result<(), GenericError> {
1153 self.started.fetch_add(1, Ordering::SeqCst);
1154 std::future::pending::<()>().await;
1155 Ok(())
1156 }
1157 }
1158
1159 struct StuckDestinationBuilder {
1160 input_event_ty: EventType,
1161 started: Arc<AtomicUsize>,
1162 }
1163
1164 #[async_trait]
1165 impl DestinationBuilder for StuckDestinationBuilder {
1166 fn input_event_type(&self) -> EventType {
1167 self.input_event_ty
1168 }
1169
1170 async fn build(&self, _: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1171 Ok(Box::new(StuckDestination {
1172 started: Arc::clone(&self.started),
1173 }))
1174 }
1175 }
1176
1177 impl MemoryBounds for StuckDestinationBuilder {
1178 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1179 }
1180
1181 fn long_running_blueprint(spawned_child: Option<Arc<AtomicUsize>>) -> (TopologyBlueprint, Arc<AtomicUsize>) {
1189 let source_started = Arc::new(AtomicUsize::new(0));
1190 let component_registry = ComponentRegistry::default();
1191 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1192 blueprint
1193 .add_source(
1194 "source",
1195 ControlSourceBuilder::new(Arc::clone(&source_started), spawned_child),
1196 )
1197 .expect("should not fail to add source")
1198 .add_destination(
1199 "destination",
1200 DrainingDestinationBuilder {
1201 input_event_ty: EventType::EventD,
1202 },
1203 )
1204 .expect("should not fail to add destination");
1205 blueprint
1206 .connect_components_in_order(["source", "destination"])
1207 .expect("should not fail to connect components");
1208 blueprint
1209 .with_health_registry(HealthRegistry::new())
1210 .with_memory_limiter(MemoryLimiter::noop())
1211 .with_resource_registry(ResourceRegistry::new());
1212 (blueprint, source_started)
1213 }
1214
1215 fn stuck_destination_blueprint(shutdown_timeout: Duration) -> (TopologyBlueprint, Arc<AtomicUsize>) {
1224 let destination_started = Arc::new(AtomicUsize::new(0));
1225 let component_registry = ComponentRegistry::default();
1226 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1227 blueprint
1228 .add_source("source", ControlSourceBuilder::new(Arc::new(AtomicUsize::new(0)), None))
1229 .expect("should not fail to add source")
1230 .add_destination(
1231 "destination",
1232 StuckDestinationBuilder {
1233 input_event_ty: EventType::EventD,
1234 started: Arc::clone(&destination_started),
1235 },
1236 )
1237 .expect("should not fail to add destination");
1238 blueprint
1239 .connect_components_in_order(["source", "destination"])
1240 .expect("should not fail to connect components");
1241 blueprint
1242 .with_health_registry(HealthRegistry::new())
1243 .with_memory_limiter(MemoryLimiter::noop())
1244 .with_resource_registry(ResourceRegistry::new())
1245 .with_shutdown_timeout(shutdown_timeout);
1246 (blueprint, destination_started)
1247 }
1248
1249 fn spawn_supervised_blueprint(
1255 blueprint: TopologyBlueprint,
1256 ) -> (
1257 oneshot::Sender<()>,
1258 tokio::task::JoinHandle<Result<(), SupervisorError>>,
1259 ) {
1260 let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1261 supervisor.add_worker(blueprint);
1262
1263 let (tx, rx) = oneshot::channel::<()>();
1264 let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1265 (tx, handle)
1266 }
1267
1268 async fn run_blueprint_until_exit(
1271 blueprint: TopologyBlueprint, strategy: RestartStrategy,
1272 ) -> Result<(), SupervisorError> {
1273 let mut supervisor = Supervisor::new("test-topology")
1275 .expect("should not fail to create supervisor")
1276 .with_restart_strategy(strategy);
1277 supervisor.add_worker(runtime::supervisable(blueprint).needed().build());
1278
1279 let (_tx, rx) = oneshot::channel::<()>();
1281 tokio::time::timeout(Duration::from_secs(5), supervisor.run_with_shutdown(rx))
1282 .await
1283 .expect("supervisor should exit promptly")
1284 }
1285
1286 async fn join_topology(
1288 handle: tokio::task::JoinHandle<Result<(), SupervisorError>>,
1289 ) -> Result<(), SupervisorError> {
1290 tokio::time::timeout(Duration::from_secs(5), handle)
1291 .await
1292 .expect("supervisor should exit promptly")
1293 .expect("supervisor task should not panic")
1294 }
1295
1296 fn blueprint_with_components() -> TopologyBlueprint {
1300 let component_registry = ComponentRegistry::default();
1301 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1302
1303 blueprint
1304 .add_source("source", TestSourceBuilder::default_output(EventType::EventD))
1305 .expect("should not fail to add source")
1306 .add_transform(
1307 "transform",
1308 TestTransformBuilder::default_output(EventType::EventD, EventType::EventD),
1309 )
1310 .expect("should not fail to add transform")
1311 .add_destination(
1312 "destination",
1313 TestDestinationBuilder::with_input_type(EventType::EventD),
1314 )
1315 .expect("should not fail to add destination");
1316
1317 blueprint
1318 }
1319
1320 fn blueprint_with_sources_and_destinations(source_ids: &[&str], destination_ids: &[&str]) -> TopologyBlueprint {
1325 let component_registry = ComponentRegistry::default();
1326 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1327
1328 for source_id in source_ids {
1329 blueprint
1330 .add_source(*source_id, TestSourceBuilder::default_output(EventType::EventD))
1331 .expect("should not fail to add source");
1332 }
1333
1334 for destination_id in destination_ids {
1335 blueprint
1336 .add_destination(
1337 *destination_id,
1338 TestDestinationBuilder::with_input_type(EventType::EventD),
1339 )
1340 .expect("should not fail to add destination");
1341 }
1342
1343 blueprint
1344 }
1345
1346 fn connected_pairs(blueprint: &TopologyBlueprint) -> Vec<(String, String)> {
1348 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1349 let outbound_edges = guard
1350 .as_ref()
1351 .expect("topology blueprint already initialized")
1352 .graph
1353 .get_outbound_directed_edges();
1354
1355 let mut pairs = Vec::new();
1356 for (from, outputs) in &outbound_edges {
1357 for targets in outputs.values() {
1358 for to in targets {
1359 pairs.push((from.component_id().to_string(), to.component_id().to_string()));
1360 }
1361 }
1362 }
1363 pairs.sort();
1364 pairs
1365 }
1366
1367 #[test]
1368 fn connect_components_in_order_errors_with_fewer_than_two_ids() {
1369 let mut blueprint = blueprint_with_components();
1370
1371 let result = blueprint.connect_components_in_order(Vec::<&str>::new()).map(|_| ());
1373 assert!(result.is_err());
1374
1375 let result = blueprint.connect_components_in_order(["source"]).map(|_| ());
1377 assert!(result.is_err());
1378
1379 assert!(connected_pairs(&blueprint).is_empty());
1381 }
1382
1383 #[test]
1384 fn connect_components_in_order_connects_pairwise_left_to_right() {
1385 let mut blueprint = blueprint_with_components();
1386
1387 blueprint
1388 .connect_components_in_order(["source", "transform", "destination"])
1389 .expect("should not fail to connect components in order");
1390
1391 assert_eq!(
1394 connected_pairs(&blueprint),
1395 vec![
1396 ("source".to_string(), "transform".to_string()),
1397 ("transform".to_string(), "destination".to_string()),
1398 ],
1399 );
1400 }
1401
1402 #[test]
1403 fn connect_component_one_to_many_fans_out() {
1404 let mut blueprint = blueprint_with_sources_and_destinations(&["source"], &["dest_a", "dest_b"]);
1407
1408 blueprint
1409 .connect_components("source", ["dest_a", "dest_b"])
1410 .expect("should not fail to connect component");
1411
1412 assert_eq!(
1413 connected_pairs(&blueprint),
1414 vec![
1415 ("source".to_string(), "dest_a".to_string()),
1416 ("source".to_string(), "dest_b".to_string()),
1417 ],
1418 );
1419 }
1420
1421 #[test]
1422 fn connect_component_many_to_one_fans_in() {
1423 let mut blueprint = blueprint_with_sources_and_destinations(&["source_a", "source_b"], &["dest"]);
1426
1427 blueprint
1428 .connect_components(["source_a", "source_b"], "dest")
1429 .expect("should not fail to connect component");
1430
1431 assert_eq!(
1432 connected_pairs(&blueprint),
1433 vec![
1434 ("source_a".to_string(), "dest".to_string()),
1435 ("source_b".to_string(), "dest".to_string()),
1436 ],
1437 );
1438 }
1439
1440 #[test]
1441 fn connect_component_many_to_many_creates_mesh() {
1442 let mut blueprint = blueprint_with_sources_and_destinations(&["source_a", "source_b"], &["dest_a", "dest_b"]);
1445
1446 blueprint
1447 .connect_components(["source_a", "source_b"], ["dest_a", "dest_b"])
1448 .expect("should not fail to connect component");
1449
1450 assert_eq!(
1451 connected_pairs(&blueprint),
1452 vec![
1453 ("source_a".to_string(), "dest_a".to_string()),
1454 ("source_a".to_string(), "dest_b".to_string()),
1455 ("source_b".to_string(), "dest_a".to_string()),
1456 ("source_b".to_string(), "dest_b".to_string()),
1457 ],
1458 );
1459 }
1460
1461 fn connected_blueprint() -> TopologyBlueprint {
1463 let mut blueprint = blueprint_with_components();
1464 blueprint
1465 .connect_components_in_order(["source", "transform", "destination"])
1466 .expect("should not fail to connect components");
1467 blueprint
1468 }
1469
1470 #[tokio::test]
1471 async fn topology_failure_shuts_down_supervisor_without_a_restart() {
1472 let mut blueprint = connected_blueprint();
1480 blueprint
1481 .with_health_registry(HealthRegistry::new())
1482 .with_memory_limiter(MemoryLimiter::noop())
1483 .with_resource_registry(ResourceRegistry::new());
1484 assert!(!blueprint.can_restart());
1485
1486 let result = run_blueprint_until_exit(blueprint, RestartStrategy::default()).await;
1487
1488 assert!(
1489 matches!(result, Err(SupervisorError::SignificantChildExited)),
1490 "the topology failure should stop the supervisor: {result:?}"
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn topology_waits_for_environment_readiness_before_starting() {
1496 let gate_reached = Arc::new(AtomicUsize::new(0));
1503 let gate = {
1504 let gate_reached = Arc::clone(&gate_reached);
1505 async move {
1506 gate_reached.fetch_add(1, Ordering::SeqCst);
1507 std::future::pending::<()>().await;
1508 }
1509 };
1510
1511 let mut blueprint = connected_blueprint();
1512 blueprint
1513 .with_health_registry(HealthRegistry::new())
1514 .with_memory_limiter(MemoryLimiter::noop())
1515 .with_resource_registry(ResourceRegistry::new())
1516 .with_environment_readiness(gate);
1517
1518 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1519
1520 wait_until("the topology reached the readiness gate", || {
1523 gate_reached.load(Ordering::SeqCst) >= 1
1524 })
1525 .await;
1526 tx.send(()).expect("should send shutdown signal");
1527
1528 let result = join_topology(handle).await;
1529 assert!(result.is_ok(), "supervisor should shut down cleanly, got: {:?}", result);
1530 }
1531
1532 #[test]
1533 fn topology_ready_waits_for_registration_before_checking_readiness() {
1534 use tokio_test::{assert_pending, assert_ready, task::spawn};
1535
1536 let health_registry = HealthRegistry::new();
1537
1538 let mut other = health_registry
1541 .register_component(&SubsystemIdentifier::from_dotted("env_provider.workload.foo"))
1542 .expect("should register component");
1543 other.mark_ready();
1544
1545 let (registered_tx, registered_rx) = oneshot::channel();
1546 let topology_ready = TopologyReady {
1547 registered_rx,
1548 health_registry: health_registry.clone(),
1549 component_root: topology_identifier("primary"),
1550 };
1551
1552 let mut wait = spawn(topology_ready.wait());
1553
1554 assert_pending!(wait.poll());
1557
1558 let mut source = health_registry
1560 .register_component(&SubsystemIdentifier::from_dotted("topology.primary.sources.in"))
1561 .expect("should register component");
1562
1563 registered_tx.send(()).expect("receiver should be alive");
1566 assert_pending!(wait.poll());
1567
1568 source.mark_ready();
1570 assert!(assert_ready!(wait.poll()));
1571 }
1572
1573 #[tokio::test]
1574 async fn topology_ready_returns_false_when_torn_down_before_registration() {
1575 let health_registry = HealthRegistry::new();
1576
1577 let (registered_tx, registered_rx) = oneshot::channel::<()>();
1578 let topology_ready = TopologyReady {
1579 registered_rx,
1580 health_registry,
1581 component_root: topology_identifier("primary"),
1582 };
1583
1584 drop(registered_tx);
1587
1588 assert!(!topology_ready.wait().await);
1589 }
1590
1591 #[tokio::test]
1592 async fn topology_clean_shutdown_returns_ok() {
1593 let (blueprint, source_started) = long_running_blueprint(None);
1598 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1599
1600 wait_until("the source has started", || source_started.load(Ordering::SeqCst) == 1).await;
1603 tx.send(()).expect("should send shutdown signal");
1604
1605 let result = join_topology(handle).await;
1606 assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1607 }
1608
1609 #[tokio::test]
1610 async fn component_can_spawn_dynamic_child_via_spawn_handle() {
1611 let child_started = Arc::new(AtomicUsize::new(0));
1616 let (blueprint, _source_started) = long_running_blueprint(Some(Arc::clone(&child_started)));
1617 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1618
1619 wait_until("the dynamic child has started", || {
1621 child_started.load(Ordering::SeqCst) == 1
1622 })
1623 .await;
1624 assert_eq!(
1625 child_started.load(Ordering::SeqCst),
1626 1,
1627 "dynamic child should have started exactly once"
1628 );
1629
1630 tx.send(()).expect("should send shutdown signal");
1631 let result = join_topology(handle).await;
1632 assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1633 }
1634
1635 #[tokio::test]
1636 async fn each_component_runs_as_a_worker_named_by_its_canonical_identity() {
1637 let (blueprint, source_started) = long_running_blueprint(None);
1642
1643 let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1644 let tree = supervisor.tree_handle();
1645 supervisor.add_worker(blueprint);
1646 let (tx, rx) = oneshot::channel::<()>();
1647 let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1648
1649 wait_until("the source has started", || source_started.load(Ordering::SeqCst) == 1).await;
1650
1651 fn find<'a>(node: &'a runtime::NodeSnapshot, name: &str) -> Option<&'a runtime::NodeSnapshot> {
1652 if node.name == name {
1653 return Some(node);
1654 }
1655 node.children.iter().find_map(|child| find(child, name))
1656 }
1657
1658 let root = tree.snapshot().root;
1659 let topology = find(&root, "topology.test").expect("the topology supervisor is in the tree");
1660 for (name, identity) in [
1661 ("sources.source", "topology.test.sources.source"),
1662 ("destinations.destination", "topology.test.destinations.destination"),
1663 ] {
1664 let component = topology
1665 .children
1666 .iter()
1667 .find(|child| child.name == name)
1668 .unwrap_or_else(|| panic!("{name} should run directly under the topology supervisor"));
1669 assert_eq!(component.kind, runtime::NodeKind::Worker);
1670 assert_eq!(component.process_name.as_deref(), Some(identity));
1671 assert_eq!(
1672 component.resource_group.as_deref(),
1673 Some(identity),
1674 "{name} should own its resource group"
1675 );
1676 }
1677
1678 tx.send(()).expect("should send shutdown signal");
1679 let result = join_topology(handle).await;
1680 assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1681 }
1682
1683 #[tokio::test]
1684 async fn work_spawned_while_a_component_is_built_belongs_to_the_component() {
1685 let source_started = Arc::new(AtomicUsize::new(0));
1690 let child_started = Arc::new(AtomicUsize::new(0));
1691 let child_stopped = Arc::new(AtomicUsize::new(0));
1692
1693 let component_registry = ComponentRegistry::default();
1694 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1695 blueprint
1696 .add_source(
1697 "spawner",
1698 BuildSpawningSourceBuilder {
1699 outputs: vec![OutputDefinition::default_output(EventType::EventD)],
1700 source_started: Arc::clone(&source_started),
1701 child_started: Arc::clone(&child_started),
1702 child_stopped: Arc::clone(&child_stopped),
1703 },
1704 )
1705 .expect("should not fail to add source")
1706 .add_destination(
1707 "destination",
1708 DrainingDestinationBuilder {
1709 input_event_ty: EventType::EventD,
1710 },
1711 )
1712 .expect("should not fail to add destination");
1713 blueprint
1714 .connect_components_in_order(["spawner", "destination"])
1715 .expect("should not fail to connect components");
1716 blueprint
1717 .with_health_registry(HealthRegistry::new())
1718 .with_memory_limiter(MemoryLimiter::noop())
1719 .with_resource_registry(ResourceRegistry::new());
1720
1721 let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1722 let tree = supervisor.tree_handle();
1723 supervisor.add_worker(blueprint);
1724 let (tx, rx) = oneshot::channel::<()>();
1725 let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1726
1727 fn find<'a>(node: &'a runtime::NodeSnapshot, name: &str) -> Option<&'a runtime::NodeSnapshot> {
1728 if node.name == name {
1729 return Some(node);
1730 }
1731 node.children.iter().find_map(|child| find(child, name))
1732 }
1733
1734 wait_until("the child spawned while building runs beneath its component", || {
1736 let root = tree.snapshot().root;
1737 find(&root, "sources.spawner").is_some_and(|source| find(source, "built_child").is_some())
1738 })
1739 .await;
1740 assert_eq!(source_started.load(Ordering::SeqCst), 1);
1741 assert_eq!(child_started.load(Ordering::SeqCst), 1);
1742
1743 tx.send(()).expect("should send shutdown signal");
1744 let result = join_topology(handle).await;
1745 assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1746 assert_eq!(
1747 child_stopped.load(Ordering::SeqCst),
1748 1,
1749 "the child should have been stopped with its component"
1750 );
1751 }
1752
1753 #[tokio::test]
1754 async fn forced_abort_count_survives_topology_boundary_to_root() {
1755 let (blueprint, destination_started) = stuck_destination_blueprint(Duration::from_millis(100));
1765 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1766
1767 wait_until("the stuck destination has started", || {
1770 destination_started.load(Ordering::SeqCst) == 1
1771 })
1772 .await;
1773 tx.send(()).expect("should send shutdown signal");
1774
1775 let result = join_topology(handle).await;
1776 assert!(
1777 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
1778 "a component that ignored shutdown must surface as an unclean shutdown with a count of 1, got {result:?}"
1779 );
1780 }
1781
1782 #[test]
1783 fn component_identity_is_byte_identical_across_subsystems() {
1784 for (topology_name, raw_id) in [("primary", "dsd_in"), ("secondary", "dsd_agg")] {
1789 let topology_root = topology_identifier(topology_name);
1791 let component_context = ComponentContext::source(&topology_root, ComponentId::try_from(raw_id).unwrap());
1792 let component_canonical_id = component_context.identity().to_string();
1793 let component_relative_id =
1794 get_component_relative_identifier(component_context.component_type(), component_context.component_id());
1795
1796 assert_eq!(
1797 component_canonical_id,
1798 format!("topology.{topology_name}.sources.{raw_id}")
1799 );
1800
1801 let registry = ComponentRegistry::default();
1804 registry
1805 .bounds_builder(&component_context.identity())
1806 .firm()
1807 .with_fixed_amount("marker", 1);
1808
1809 let bounds = registry.as_bounds();
1810 let mut node = &bounds;
1811 for segment in component_canonical_id.split('.') {
1812 node = node
1813 .subcomponents()
1814 .into_iter()
1815 .find_map(|(name, child)| (name.as_str() == segment).then_some(child))
1816 .expect("resource-accounting node path must match the canonical identity segment by segment");
1817 }
1818 assert_eq!(
1819 node.total_firm_limit_bytes(),
1820 1,
1821 "the resource-accounting node at the canonical path must hold the declared bounds"
1822 );
1823
1824 let topology_sup = Name::root(topology_root.to_string()).expect("topology name is non-empty");
1827 let process_name =
1828 Name::scoped(&topology_sup, component_relative_id.to_string()).expect("component name is non-empty");
1829 assert_eq!(
1830 &*process_name,
1831 component_canonical_id.as_str(),
1832 "component worker process name must match the canonical identity"
1833 );
1834 }
1835 }
1836
1837 #[test]
1838 fn recalculate_bounds_accounts_for_interconnect_and_event_buffer_memory() {
1839 let interconnect_capacity = 4usize;
1844 let mut blueprint = blueprint_with_components();
1845 blueprint.with_interconnect_capacity(NonZeroUsize::new(interconnect_capacity).unwrap());
1846
1847 let (sources, transforms, destinations, decoders) = (1usize, 1usize, 1usize, 0usize);
1849
1850 let total_interconnect_capacity = interconnect_capacity * (transforms + destinations);
1853 let expected_min = total_interconnect_capacity * std::mem::size_of::<EventsBuffer>();
1854
1855 let max_in_flight = ((transforms + destinations) * interconnect_capacity) + sources + decoders + transforms;
1858 let per_buffer =
1859 std::mem::size_of::<EventsBuffer>() + (std::mem::size_of::<Event>() * DEFAULT_EVENTS_BUFFER_CAPACITY);
1860 let expected_firm = expected_min + (max_in_flight * per_buffer);
1861
1862 let bounds = {
1863 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1864 guard
1865 .as_ref()
1866 .expect("topology blueprint already initialized")
1867 .component_registry
1868 .as_bounds()
1869 };
1870
1871 assert_eq!(
1872 bounds.total_minimum_required_bytes(),
1873 expected_min,
1874 "interconnect minimum bytes should be capacity * non-source components * size_of::<EventsBuffer>()"
1875 );
1876 assert_eq!(
1877 bounds.total_firm_limit_bytes(),
1878 expected_firm,
1879 "firm bytes should add the max in-flight event-buffer memory on top of the minimum"
1880 );
1881 }
1882
1883 #[test]
1884 fn worker_pool_configuration_defaults_to_dedicated() {
1885 let blueprint = blueprint_with_components();
1886 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1887 let config = &guard
1888 .as_ref()
1889 .expect("topology blueprint already initialized")
1890 .worker_pool_config;
1891 assert!(
1892 matches!(config, WorkerPoolConfiguration::Dedicated),
1893 "the default worker-pool configuration must be dedicated"
1894 );
1895 }
1896
1897 #[test]
1898 fn with_ambient_worker_pool_selects_ambient() {
1899 let mut blueprint = blueprint_with_components();
1900 blueprint.with_ambient_worker_pool();
1901
1902 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1903 let config = &guard
1904 .as_ref()
1905 .expect("topology blueprint already initialized")
1906 .worker_pool_config;
1907 assert!(matches!(config, WorkerPoolConfiguration::Ambient));
1908 }
1909
1910 #[tokio::test]
1911 async fn with_explicit_worker_pool_selects_explicit() {
1912 let mut blueprint = blueprint_with_components();
1913 blueprint.with_explicit_worker_pool(tokio::runtime::Handle::current());
1914
1915 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1916 let config = &guard
1917 .as_ref()
1918 .expect("topology blueprint already initialized")
1919 .worker_pool_config;
1920 assert!(matches!(config, WorkerPoolConfiguration::Explicit(_)));
1921 }
1922}