1use std::{collections::HashMap, future::Future, num::NonZeroUsize, pin::Pin, sync::Mutex, time::Duration};
2
3use async_trait::async_trait;
4use saluki_common::resource_tracking::Track as _;
5use saluki_common::sync::shutdown::ShutdownHandle;
6use saluki_error::{generic_error, ErrorContext as _, GenericError};
7use snafu::Snafu;
8use tokio::{pin, runtime::Handle, select, sync::oneshot};
9use tracing::info;
10
11use super::{
12 built::{BuiltTopology, WorkerPoolConfiguration},
13 graph::{Graph, GraphError},
14 ComponentId,
15};
16use crate::accounting::{ComponentRegistry, MemoryLimiter, UsageExpr};
17use crate::{
18 components::{
19 decoders::DecoderBuilder, destinations::DestinationBuilder, encoders::EncoderBuilder,
20 forwarders::ForwarderBuilder, relays::RelayBuilder, sources::SourceBuilder, transforms::TransformBuilder,
21 BuildContext, ComponentContext, ComponentType,
22 },
23 data_model::event::Event,
24 health::HealthRegistry,
25 runtime::{
26 state::{DataspaceRegistry, ResourceRegistry},
27 InitializationError, ShutdownStrategy, Supervisable, SupervisorFuture,
28 },
29 support::SubsystemIdentifier,
30 topology::{ids::AsComponentIds, topology_identifier, EventsBuffer, DEFAULT_EVENTS_BUFFER_CAPACITY},
31};
32
33const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
34
35#[derive(Debug, Snafu)]
37#[snafu(context(suffix(false)))]
38pub enum BlueprintError {
39 #[snafu(display("Failed to build/validate topology graph: {}", source))]
41 InvalidGraph {
42 source: GraphError,
44 },
45
46 #[snafu(display("Failed to build component '{}': {}", id, source))]
48 FailedToBuildComponent {
49 id: ComponentId,
51
52 source: GenericError,
54 },
55}
56
57pub struct TopologyBlueprint {
63 name: String,
64 build_state: Mutex<Option<TopologyBuildState>>,
65 health_registry: Option<HealthRegistry>,
66 memory_limiter: Option<MemoryLimiter>,
67 resource_registry: Option<ResourceRegistry>,
68}
69
70struct TopologyBuildState {
74 topology_id: SubsystemIdentifier,
75 graph: Graph,
76 sources: HashMap<ComponentId, Box<dyn SourceBuilder + Send>>,
77 relays: HashMap<ComponentId, Box<dyn RelayBuilder + Send>>,
78 decoders: HashMap<ComponentId, Box<dyn DecoderBuilder + Send>>,
79 transforms: HashMap<ComponentId, Box<dyn TransformBuilder + Send>>,
80 destinations: HashMap<ComponentId, Box<dyn DestinationBuilder + Send>>,
81 encoders: HashMap<ComponentId, Box<dyn EncoderBuilder + Send>>,
82 forwarders: HashMap<ComponentId, Box<dyn ForwarderBuilder + Send>>,
83 component_registry: ComponentRegistry,
84 interconnect_capacity: NonZeroUsize,
85 shutdown_timeout: Duration,
86 worker_pool_config: WorkerPoolConfiguration,
87 environment_ready: Option<Pin<Box<dyn Future<Output = ()> + Send>>>,
88 ready_signal: Option<oneshot::Sender<()>>,
89}
90
91impl TopologyBlueprint {
92 pub fn new(name: &str, component_registry: &ComponentRegistry) -> Self {
94 let topology_id = topology_identifier(name);
95 let component_registry = component_registry.clone();
96
97 let build_state = TopologyBuildState {
98 topology_id,
99 graph: Graph::default(),
100 sources: HashMap::new(),
101 relays: HashMap::new(),
102 decoders: HashMap::new(),
103 transforms: HashMap::new(),
104 destinations: HashMap::new(),
105 encoders: HashMap::new(),
106 forwarders: HashMap::new(),
107 component_registry,
108 interconnect_capacity: super::DEFAULT_INTERCONNECT_CAPACITY,
109 shutdown_timeout: DEFAULT_SHUTDOWN_TIMEOUT,
110 worker_pool_config: WorkerPoolConfiguration::Dedicated,
111 environment_ready: None,
112 ready_signal: None,
113 };
114
115 Self {
116 name: name.to_string(),
117 build_state: Mutex::new(Some(build_state)),
118 health_registry: None,
119 memory_limiter: None,
120 resource_registry: None,
121 }
122 }
123
124 fn state_mut(&mut self) -> &mut TopologyBuildState {
130 self.build_state
131 .get_mut()
132 .expect("topology blueprint mutex poisoned")
133 .as_mut()
134 .expect("topology blueprint already initialized")
135 }
136
137 pub fn with_interconnect_capacity(&mut self, capacity: NonZeroUsize) -> &mut Self {
145 self.state_mut().set_interconnect_capacity(capacity);
146 self
147 }
148
149 pub fn with_shutdown_timeout(&mut self, timeout: Duration) -> &mut Self {
153 self.state_mut().shutdown_timeout = timeout;
154 self
155 }
156
157 pub fn with_health_registry(&mut self, health_registry: HealthRegistry) -> &mut Self {
161 self.health_registry = Some(health_registry);
162 self
163 }
164
165 pub fn with_memory_limiter(&mut self, memory_limiter: MemoryLimiter) -> &mut Self {
169 self.memory_limiter = Some(memory_limiter);
170 self
171 }
172
173 pub fn with_resource_registry(&mut self, resource_registry: ResourceRegistry) -> &mut Self {
181 self.resource_registry = Some(resource_registry);
182 self
183 }
184
185 pub fn with_environment_readiness<F>(&mut self, ready: F) -> &mut Self
191 where
192 F: Future<Output = ()> + Send + 'static,
193 {
194 self.state_mut().environment_ready = Some(Box::pin(ready));
195 self
196 }
197
198 pub fn topology_ready(&mut self) -> TopologyReady {
207 let health_registry = self
208 .health_registry
209 .clone()
210 .expect("health registry must be set before acquiring a topology readiness handle");
211 let component_root = super::topology_identifier(&self.name);
212
213 let (registered_tx, registered_rx) = oneshot::channel();
214 self.state_mut().ready_signal = Some(registered_tx);
215
216 TopologyReady {
217 registered_rx,
218 health_registry,
219 component_root,
220 }
221 }
222
223 pub fn with_ambient_worker_pool(&mut self) -> &mut Self {
228 self.state_mut().worker_pool_config = WorkerPoolConfiguration::Ambient;
229 self
230 }
231
232 pub fn with_explicit_worker_pool(&mut self, handle: Handle) -> &mut Self {
236 self.state_mut().worker_pool_config = WorkerPoolConfiguration::Explicit(handle);
237 self
238 }
239
240 pub fn add_source<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
246 where
247 I: AsRef<str>,
248 B: SourceBuilder + Send + 'static,
249 {
250 self.state_mut().add_source(component_id, builder)?;
251 Ok(self)
252 }
253
254 pub fn add_relay<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
260 where
261 I: AsRef<str>,
262 B: RelayBuilder + Send + 'static,
263 {
264 self.state_mut().add_relay(component_id, builder)?;
265 Ok(self)
266 }
267
268 pub fn add_decoder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
274 where
275 I: AsRef<str>,
276 B: DecoderBuilder + Send + 'static,
277 {
278 self.state_mut().add_decoder(component_id, builder)?;
279 Ok(self)
280 }
281
282 pub fn add_transform<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
288 where
289 I: AsRef<str>,
290 B: TransformBuilder + Send + 'static,
291 {
292 self.state_mut().add_transform(component_id, builder)?;
293 Ok(self)
294 }
295
296 pub fn add_destination<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
302 where
303 I: AsRef<str>,
304 B: DestinationBuilder + Send + 'static,
305 {
306 self.state_mut().add_destination(component_id, builder)?;
307 Ok(self)
308 }
309
310 pub fn add_encoder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
316 where
317 I: AsRef<str>,
318 B: EncoderBuilder + Send + 'static,
319 {
320 self.state_mut().add_encoder(component_id, builder)?;
321 Ok(self)
322 }
323
324 pub fn add_forwarder<I, B>(&mut self, component_id: I, builder: B) -> Result<&mut Self, GenericError>
330 where
331 I: AsRef<str>,
332 B: ForwarderBuilder + Send + 'static,
333 {
334 self.state_mut().add_forwarder(component_id, builder)?;
335 Ok(self)
336 }
337
338 pub fn connect_components<MS, SI, MD, DI>(
353 &mut self, upstream_output_component_ids: SI, downstream_component_ids: DI,
354 ) -> Result<&mut Self, GenericError>
355 where
356 SI: AsComponentIds<MS>,
357 DI: AsComponentIds<MD>,
358 {
359 self.state_mut()
360 .connect_components(upstream_output_component_ids, downstream_component_ids)?;
361 Ok(self)
362 }
363
364 pub fn connect_components_in_order<IT, I>(&mut self, ordered_component_ids: IT) -> Result<&mut Self, GenericError>
386 where
387 IT: IntoIterator<Item = I>,
388 I: AsRef<str>,
389 {
390 self.state_mut().connect_components_in_order(ordered_component_ids)?;
391 Ok(self)
392 }
393}
394
395pub struct TopologyReady {
397 registered_rx: oneshot::Receiver<()>,
398 health_registry: HealthRegistry,
399 component_root: SubsystemIdentifier,
400}
401
402impl TopologyReady {
403 pub async fn wait(self) -> bool {
409 if self.registered_rx.await.is_err() {
414 return false;
415 }
416
417 self.health_registry.all_ready_under(self.component_root).await;
419
420 true
421 }
422}
423
424impl TopologyBuildState {
425 fn set_interconnect_capacity(&mut self, capacity: NonZeroUsize) {
426 self.interconnect_capacity = capacity;
427 self.recalculate_bounds();
428 }
429
430 fn recalculate_bounds(&mut self) {
431 let interconnect_capacity = self.interconnect_capacity.get();
432
433 let mut bounds_builder = self.component_registry.bounds_builder(&self.topology_id);
434 let mut bounds_builder = bounds_builder.subcomponent("interconnects");
435 bounds_builder.reset();
436
437 let total_interconnect_capacity = interconnect_capacity * (self.transforms.len() + self.destinations.len());
442 bounds_builder
443 .minimum()
444 .with_array::<EventsBuffer>("events", total_interconnect_capacity);
445
446 let max_in_flight_event_buffers = ((self.transforms.len() + self.destinations.len()) * interconnect_capacity)
457 + self.sources.len()
458 + self.decoders.len()
459 + self.transforms.len();
460
461 bounds_builder
462 .firm()
463 .with_expr(UsageExpr::product(
465 "events",
466 UsageExpr::constant("max in-flight event buffers", max_in_flight_event_buffers),
467 UsageExpr::sum(
468 "",
469 UsageExpr::struct_size::<EventsBuffer>("events buffer"),
470 UsageExpr::product(
471 "",
472 UsageExpr::struct_size::<Event>("event"),
473 UsageExpr::constant("default event buffer capacity", DEFAULT_EVENTS_BUFFER_CAPACITY),
474 ),
475 ),
476 ));
477 }
478
479 fn component_identity(&self, component_type: ComponentType, component_id: &ComponentId) -> SubsystemIdentifier {
480 ComponentContext::new(&self.topology_id, component_id.clone(), component_type).identity()
481 }
482
483 fn add_source<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
484 where
485 I: AsRef<str>,
486 B: SourceBuilder + Send + 'static,
487 {
488 let component_id = self
489 .graph
490 .add_source(component_id, &builder)
491 .error_context("Failed to add source to topology graph.")?;
492
493 let identity = self.component_identity(ComponentType::Source, &component_id);
494 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
495
496 self.recalculate_bounds();
497
498 let _ = self.sources.insert(component_id, Box::new(builder));
499
500 Ok(())
501 }
502
503 fn add_relay<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
504 where
505 I: AsRef<str>,
506 B: RelayBuilder + Send + 'static,
507 {
508 let component_id = self
509 .graph
510 .add_relay(component_id, &builder)
511 .error_context("Failed to add relay to topology graph.")?;
512
513 let identity = self.component_identity(ComponentType::Relay, &component_id);
514 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
515
516 self.recalculate_bounds();
517
518 let _ = self.relays.insert(component_id, Box::new(builder));
519
520 Ok(())
521 }
522
523 fn add_decoder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
524 where
525 I: AsRef<str>,
526 B: DecoderBuilder + Send + 'static,
527 {
528 let component_id = self
529 .graph
530 .add_decoder(component_id, &builder)
531 .error_context("Failed to add decoder to topology graph.")?;
532
533 let identity = self.component_identity(ComponentType::Decoder, &component_id);
534 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
535
536 self.recalculate_bounds();
537
538 let _ = self.decoders.insert(component_id, Box::new(builder));
539
540 Ok(())
541 }
542
543 fn add_transform<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
544 where
545 I: AsRef<str>,
546 B: TransformBuilder + Send + 'static,
547 {
548 let component_id = self
549 .graph
550 .add_transform(component_id, &builder)
551 .error_context("Failed to add transform to topology graph.")?;
552
553 let identity = self.component_identity(ComponentType::Transform, &component_id);
554 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
555
556 self.recalculate_bounds();
557
558 let _ = self.transforms.insert(component_id, Box::new(builder));
559
560 Ok(())
561 }
562
563 fn add_destination<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
564 where
565 I: AsRef<str>,
566 B: DestinationBuilder + Send + 'static,
567 {
568 let component_id = self
569 .graph
570 .add_destination(component_id, &builder)
571 .error_context("Failed to add destination to topology graph.")?;
572
573 let identity = self.component_identity(ComponentType::Destination, &component_id);
574 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
575
576 self.recalculate_bounds();
577
578 let _ = self.destinations.insert(component_id, Box::new(builder));
579
580 Ok(())
581 }
582
583 fn add_encoder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
584 where
585 I: AsRef<str>,
586 B: EncoderBuilder + Send + 'static,
587 {
588 let component_id = self
589 .graph
590 .add_encoder(component_id, &builder)
591 .error_context("Failed to add encoder to topology graph.")?;
592
593 let identity = self.component_identity(ComponentType::Encoder, &component_id);
594 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
595
596 self.recalculate_bounds();
597
598 let _ = self.encoders.insert(component_id, Box::new(builder));
599
600 Ok(())
601 }
602
603 fn add_forwarder<I, B>(&mut self, component_id: I, builder: B) -> Result<(), GenericError>
604 where
605 I: AsRef<str>,
606 B: ForwarderBuilder + Send + 'static,
607 {
608 let component_id = self
609 .graph
610 .add_forwarder(component_id, &builder)
611 .error_context("Failed to add forwarder to topology graph.")?;
612
613 let identity = self.component_identity(ComponentType::Forwarder, &component_id);
614 builder.specify_bounds(&mut self.component_registry.bounds_builder(&identity));
615
616 self.recalculate_bounds();
617
618 let _ = self.forwarders.insert(component_id, Box::new(builder));
619
620 Ok(())
621 }
622
623 fn connect_components<MS, SI, MD, DI>(
624 &mut self, upstream_output_component_ids: SI, downstream_component_ids: DI,
625 ) -> Result<(), GenericError>
626 where
627 SI: AsComponentIds<MS>,
628 DI: AsComponentIds<MD>,
629 {
630 for upstream_output_component_id in upstream_output_component_ids.as_component_ids() {
631 for downstream_component_id in downstream_component_ids.as_component_ids() {
632 self.graph
633 .add_edge(upstream_output_component_id.as_ref(), downstream_component_id.as_ref())
634 .error_context("Failed to add component connection to topology graph.")?;
635 }
636 }
637
638 Ok(())
639 }
640
641 fn connect_components_in_order<IT, I>(&mut self, ordered_component_ids: IT) -> Result<(), GenericError>
642 where
643 IT: IntoIterator<Item = I>,
644 I: AsRef<str>,
645 {
646 let mut pending_output_component_id: Option<I> = None;
647 let mut connected_any = false;
648
649 for component_id in ordered_component_ids.into_iter() {
650 if let Some(output_component_id) = pending_output_component_id.take() {
651 self.graph
652 .add_edge(output_component_id.as_ref(), component_id.as_ref())
653 .error_context("Failed to add component connection to topology graph.")?;
654
655 connected_any = true;
656 }
657
658 pending_output_component_id = Some(component_id);
660 }
661
662 if !connected_any {
664 return Err(generic_error!(
665 "Two or more components must be provided for connection."
666 ));
667 }
668
669 Ok(())
670 }
671
672 async fn build(self, name: String, resource_registry: &ResourceRegistry) -> Result<BuiltTopology, GenericError> {
678 self.graph.validate().error_context("Failed to build topology graph.")?;
679
680 let mut sources = HashMap::new();
681 for (id, builder) in self.sources {
682 let component_context = ComponentContext::source(&self.topology_id, id.clone());
683 let allocation_token = self
684 .component_registry
685 .get_resource_group_token(&component_context.identity());
686 let source = builder
687 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
688 .track_resources(allocation_token)
689 .await
690 .with_error_context(|| format!("Failed to build source '{}'.", id))?;
691
692 sources.insert(component_context, (source, self.component_registry.clone()));
693 }
694
695 let mut relays = HashMap::new();
696 for (id, builder) in self.relays {
697 let component_context = ComponentContext::relay(&self.topology_id, id.clone());
698 let allocation_token = self
699 .component_registry
700 .get_resource_group_token(&component_context.identity());
701 let relay = builder
702 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
703 .track_resources(allocation_token)
704 .await
705 .with_error_context(|| format!("Failed to build relay '{}'.", id))?;
706
707 relays.insert(component_context, (relay, self.component_registry.clone()));
708 }
709
710 let mut decoders = HashMap::new();
711 for (id, builder) in self.decoders {
712 let component_context = ComponentContext::decoder(&self.topology_id, id.clone());
713 let allocation_token = self
714 .component_registry
715 .get_resource_group_token(&component_context.identity());
716 let decoder = builder
717 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
718 .track_resources(allocation_token)
719 .await
720 .with_error_context(|| format!("Failed to build decoder '{}'.", id))?;
721
722 decoders.insert(component_context, (decoder, self.component_registry.clone()));
723 }
724
725 let mut transforms = HashMap::new();
726 for (id, builder) in self.transforms {
727 let component_context = ComponentContext::transform(&self.topology_id, id.clone());
728 let allocation_token = self
729 .component_registry
730 .get_resource_group_token(&component_context.identity());
731 let transform = builder
732 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
733 .track_resources(allocation_token)
734 .await
735 .with_error_context(|| format!("Failed to build transform '{}'.", id))?;
736
737 transforms.insert(component_context, (transform, self.component_registry.clone()));
738 }
739
740 let mut destinations = HashMap::new();
741 for (id, builder) in self.destinations {
742 let component_context = ComponentContext::destination(&self.topology_id, id.clone());
743 let allocation_token = self
744 .component_registry
745 .get_resource_group_token(&component_context.identity());
746 let destination = builder
747 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
748 .track_resources(allocation_token)
749 .await
750 .with_error_context(|| format!("Failed to build destination '{}'.", id))?;
751
752 destinations.insert(component_context, (destination, self.component_registry.clone()));
753 }
754
755 let mut encoders = HashMap::new();
756 for (id, builder) in self.encoders {
757 let component_context = ComponentContext::encoder(&self.topology_id, id.clone());
758 let allocation_token = self
759 .component_registry
760 .get_resource_group_token(&component_context.identity());
761 let encoder = builder
762 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
763 .track_resources(allocation_token)
764 .await
765 .with_error_context(|| format!("Failed to build encoder '{}'.", id))?;
766
767 encoders.insert(component_context, (encoder, self.component_registry.clone()));
768 }
769
770 let mut forwarders = HashMap::new();
771 for (id, builder) in self.forwarders {
772 let component_context = ComponentContext::forwarder(&self.topology_id, id.clone());
773 let allocation_token = self
774 .component_registry
775 .get_resource_group_token(&component_context.identity());
776 let forwarder = builder
777 .build(BuildContext::new(component_context.clone(), resource_registry.clone()))
778 .track_resources(allocation_token)
779 .await
780 .with_error_context(|| format!("Failed to build forwarder '{}'.", id))?;
781
782 forwarders.insert(component_context, (forwarder, self.component_registry.clone()));
783 }
784
785 let topology_token = self.component_registry.get_resource_group_token(&self.topology_id);
786
787 Ok(BuiltTopology::from_parts(
788 name,
789 self.topology_id,
790 self.graph,
791 sources,
792 relays,
793 decoders,
794 transforms,
795 destinations,
796 encoders,
797 forwarders,
798 topology_token,
799 self.interconnect_capacity,
800 self.worker_pool_config,
801 ))
802 }
803}
804
805#[async_trait]
806impl Supervisable for TopologyBlueprint {
807 fn name(&self) -> &str {
808 &self.name
809 }
810
811 fn shutdown_strategy(&self) -> ShutdownStrategy {
812 ShutdownStrategy::Graceful(Duration::MAX)
815 }
816
817 async fn initialize(&self, shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
818 let mut build_state = self
822 .build_state
823 .lock()
824 .expect("topology blueprint mutex poisoned")
825 .take()
826 .ok_or_else(|| generic_error!("Topology has already been initialized and cannot be run more than once."))?;
827
828 let health_registry = self
829 .health_registry
830 .clone()
831 .ok_or_else(|| generic_error!("Topology blueprint is missing its health registry."))?;
832 let memory_limiter = self
833 .memory_limiter
834 .clone()
835 .ok_or_else(|| generic_error!("Topology blueprint is missing its memory limiter."))?;
836
837 let resource_registry = self
838 .resource_registry
839 .clone()
840 .ok_or_else(|| generic_error!("Topology blueprint is missing its resource registry."))?;
841
842 let dataspace = DataspaceRegistry::try_current()
843 .ok_or_else(|| generic_error!("Topology must be initialized within a supervised process context."))?;
844
845 let environment_ready = build_state.environment_ready.take();
853 let ready_signal = build_state.ready_signal.take();
854 let shutdown_timeout = build_state.shutdown_timeout;
855 let built = build_state.build(self.name.clone(), &resource_registry).await?;
856
857 Ok(Box::pin(async move {
858 pin!(shutdown);
859
860 if let Some(environment_ready) = environment_ready {
863 select! {
864 _ = &mut shutdown => return Ok(()),
865 _ = environment_ready => {},
866 }
867 }
868
869 let mut topology_sup = built
872 .spawn_inner(&health_registry, memory_limiter, dataspace.clone(), shutdown_timeout)
873 .await?;
874
875 if let Some(ready_signal) = ready_signal {
879 let _ = ready_signal.send(());
880 }
881
882 let (topology_shutdown_trigger, topology_shutdown) = ShutdownHandle::paired();
889 let run = topology_sup.run_with_shutdown_inner(topology_shutdown, Some(dataspace));
890 pin!(run);
891
892 let mut topology_shutdown_trigger = Some(topology_shutdown_trigger);
893 loop {
894 select! {
895 result = &mut run => return result.map_err(Into::into),
896 _ = &mut shutdown, if topology_shutdown_trigger.is_some() => {
897 info!("Topology received shutdown signal. Shutting down...");
898 topology_shutdown_trigger.take().expect("present per select guard").shutdown();
899 }
900 }
901 }
902 }))
903 }
904}
905
906#[cfg(test)]
907mod tests {
908 use std::num::NonZeroUsize;
909 use std::sync::atomic::{AtomicUsize, Ordering};
910 use std::sync::Arc;
911 use std::time::Duration;
912
913 use async_trait::async_trait;
914 use saluki_common::sync::shutdown::ShutdownHandle;
915 use saluki_error::GenericError;
916 use tokio::sync::oneshot;
917
918 use super::{TopologyBlueprint, TopologyReady, WorkerPoolConfiguration};
919 use crate::accounting::{ComponentRegistry, MemoryBounds, MemoryBoundsBuilder, MemoryLimiter};
920 use crate::components::BuildContext;
921 use crate::data_model::event::Event;
922 use crate::runtime::{self, state::ResourceRegistry, Name};
923 use crate::test_support::wait_until;
924 use crate::topology::{ids::get_component_relative_identifier, topology_identifier, ComponentId};
925 use crate::topology::{EventsBuffer, DEFAULT_EVENTS_BUFFER_CAPACITY};
926 use crate::{
927 components::{
928 destinations::{Destination, DestinationBuilder, DestinationContext},
929 sources::{Source, SourceBuilder, SourceContext},
930 ComponentContext,
931 },
932 data_model::event::EventType,
933 health::HealthRegistry,
934 runtime::{
935 InitializationError, RestartMode, RestartStrategy, Supervisable, Supervisor, SupervisorError,
936 SupervisorFuture,
937 },
938 support::SubsystemIdentifier,
939 topology::{
940 test_util::{TestDestinationBuilder, TestSourceBuilder, TestTransformBuilder},
941 OutputDefinition,
942 },
943 };
944
945 struct CountingChild {
949 started: Arc<AtomicUsize>,
950 }
951
952 #[async_trait]
953 impl Supervisable for CountingChild {
954 fn name(&self) -> &str {
955 "child"
956 }
957
958 async fn initialize(&self, process_shutdown: ShutdownHandle) -> Result<SupervisorFuture, InitializationError> {
959 let started = Arc::clone(&self.started);
960 Ok(Box::pin(async move {
961 started.fetch_add(1, Ordering::SeqCst);
962 process_shutdown.await;
964 Ok(())
965 }))
966 }
967 }
968
969 struct ControlSource {
973 started: Arc<AtomicUsize>,
974 spawned_child: Option<Arc<AtomicUsize>>,
975 }
976
977 #[async_trait]
978 impl Source for ControlSource {
979 async fn run(self: Box<Self>, mut context: SourceContext) -> Result<(), GenericError> {
980 self.started.fetch_add(1, Ordering::SeqCst);
981 let shutdown = context.take_shutdown_handle();
982 if let Some(started) = self.spawned_child {
983 runtime::supervisable(CountingChild { started }).spawn();
986 }
987 shutdown.await;
988 Ok(())
989 }
990 }
991
992 struct ControlSourceBuilder {
993 outputs: Vec<OutputDefinition<EventType>>,
994 started: Arc<AtomicUsize>,
995 spawned_child: Option<Arc<AtomicUsize>>,
996 }
997
998 impl ControlSourceBuilder {
999 fn new(started: Arc<AtomicUsize>, spawned_child: Option<Arc<AtomicUsize>>) -> Self {
1000 Self {
1001 outputs: vec![OutputDefinition::default_output(EventType::EventD)],
1002 started,
1003 spawned_child,
1004 }
1005 }
1006 }
1007
1008 #[async_trait]
1009 impl SourceBuilder for ControlSourceBuilder {
1010 fn outputs(&self) -> &[OutputDefinition<EventType>] {
1011 &self.outputs
1012 }
1013
1014 async fn build(&self, _: BuildContext) -> Result<Box<dyn Source + Send>, GenericError> {
1015 Ok(Box::new(ControlSource {
1016 started: Arc::clone(&self.started),
1017 spawned_child: self.spawned_child.clone(),
1018 }))
1019 }
1020 }
1021
1022 impl MemoryBounds for ControlSourceBuilder {
1023 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1024 }
1025
1026 struct DrainingDestination;
1028
1029 #[async_trait]
1030 impl Destination for DrainingDestination {
1031 async fn run(self: Box<Self>, mut context: DestinationContext) -> Result<(), GenericError> {
1032 while context.events().next().await.is_some() {}
1033 Ok(())
1034 }
1035 }
1036
1037 struct DrainingDestinationBuilder {
1038 input_event_ty: EventType,
1039 }
1040
1041 #[async_trait]
1042 impl DestinationBuilder for DrainingDestinationBuilder {
1043 fn input_event_type(&self) -> EventType {
1044 self.input_event_ty
1045 }
1046
1047 async fn build(&self, _: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1048 Ok(Box::new(DrainingDestination))
1049 }
1050 }
1051
1052 impl MemoryBounds for DrainingDestinationBuilder {
1053 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1054 }
1055
1056 struct StuckDestination {
1060 started: Arc<AtomicUsize>,
1061 }
1062
1063 #[async_trait]
1064 impl Destination for StuckDestination {
1065 async fn run(self: Box<Self>, _context: DestinationContext) -> Result<(), GenericError> {
1066 self.started.fetch_add(1, Ordering::SeqCst);
1067 std::future::pending::<()>().await;
1068 Ok(())
1069 }
1070 }
1071
1072 struct StuckDestinationBuilder {
1073 input_event_ty: EventType,
1074 started: Arc<AtomicUsize>,
1075 }
1076
1077 #[async_trait]
1078 impl DestinationBuilder for StuckDestinationBuilder {
1079 fn input_event_type(&self) -> EventType {
1080 self.input_event_ty
1081 }
1082
1083 async fn build(&self, _: BuildContext) -> Result<Box<dyn Destination + Send>, GenericError> {
1084 Ok(Box::new(StuckDestination {
1085 started: Arc::clone(&self.started),
1086 }))
1087 }
1088 }
1089
1090 impl MemoryBounds for StuckDestinationBuilder {
1091 fn specify_bounds(&self, _builder: &mut MemoryBoundsBuilder) {}
1092 }
1093
1094 fn long_running_blueprint(spawned_child: Option<Arc<AtomicUsize>>) -> (TopologyBlueprint, Arc<AtomicUsize>) {
1102 let source_started = Arc::new(AtomicUsize::new(0));
1103 let component_registry = ComponentRegistry::default();
1104 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1105 blueprint
1106 .add_source(
1107 "source",
1108 ControlSourceBuilder::new(Arc::clone(&source_started), spawned_child),
1109 )
1110 .expect("should not fail to add source")
1111 .add_destination(
1112 "destination",
1113 DrainingDestinationBuilder {
1114 input_event_ty: EventType::EventD,
1115 },
1116 )
1117 .expect("should not fail to add destination");
1118 blueprint
1119 .connect_components_in_order(["source", "destination"])
1120 .expect("should not fail to connect components");
1121 blueprint
1122 .with_health_registry(HealthRegistry::new())
1123 .with_memory_limiter(MemoryLimiter::noop())
1124 .with_resource_registry(ResourceRegistry::new());
1125 (blueprint, source_started)
1126 }
1127
1128 fn stuck_destination_blueprint(shutdown_timeout: Duration) -> (TopologyBlueprint, Arc<AtomicUsize>) {
1137 let destination_started = Arc::new(AtomicUsize::new(0));
1138 let component_registry = ComponentRegistry::default();
1139 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1140 blueprint
1141 .add_source("source", ControlSourceBuilder::new(Arc::new(AtomicUsize::new(0)), None))
1142 .expect("should not fail to add source")
1143 .add_destination(
1144 "destination",
1145 StuckDestinationBuilder {
1146 input_event_ty: EventType::EventD,
1147 started: Arc::clone(&destination_started),
1148 },
1149 )
1150 .expect("should not fail to add destination");
1151 blueprint
1152 .connect_components_in_order(["source", "destination"])
1153 .expect("should not fail to connect components");
1154 blueprint
1155 .with_health_registry(HealthRegistry::new())
1156 .with_memory_limiter(MemoryLimiter::noop())
1157 .with_resource_registry(ResourceRegistry::new())
1158 .with_shutdown_timeout(shutdown_timeout);
1159 (blueprint, destination_started)
1160 }
1161
1162 fn spawn_supervised_blueprint(
1168 blueprint: TopologyBlueprint,
1169 ) -> (
1170 oneshot::Sender<()>,
1171 tokio::task::JoinHandle<Result<(), SupervisorError>>,
1172 ) {
1173 let mut supervisor = Supervisor::new("test-topology").expect("should not fail to create supervisor");
1174 supervisor.add_worker(blueprint);
1175
1176 let (tx, rx) = oneshot::channel::<()>();
1177 let handle = tokio::spawn(async move { supervisor.run_with_shutdown(rx).await });
1178 (tx, handle)
1179 }
1180
1181 async fn run_blueprint_until_exit(
1184 blueprint: TopologyBlueprint, strategy: RestartStrategy,
1185 ) -> Result<(), SupervisorError> {
1186 let mut supervisor = Supervisor::new("test-topology")
1187 .expect("should not fail to create supervisor")
1188 .with_restart_strategy(strategy);
1189 supervisor.add_worker(blueprint);
1190
1191 let (_tx, rx) = oneshot::channel::<()>();
1193 tokio::time::timeout(Duration::from_secs(5), supervisor.run_with_shutdown(rx))
1194 .await
1195 .expect("supervisor should exit promptly")
1196 }
1197
1198 async fn join_topology(
1200 handle: tokio::task::JoinHandle<Result<(), SupervisorError>>,
1201 ) -> Result<(), SupervisorError> {
1202 tokio::time::timeout(Duration::from_secs(5), handle)
1203 .await
1204 .expect("supervisor should exit promptly")
1205 .expect("supervisor task should not panic")
1206 }
1207
1208 fn blueprint_with_components() -> TopologyBlueprint {
1212 let component_registry = ComponentRegistry::default();
1213 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1214
1215 blueprint
1216 .add_source("source", TestSourceBuilder::default_output(EventType::EventD))
1217 .expect("should not fail to add source")
1218 .add_transform(
1219 "transform",
1220 TestTransformBuilder::default_output(EventType::EventD, EventType::EventD),
1221 )
1222 .expect("should not fail to add transform")
1223 .add_destination(
1224 "destination",
1225 TestDestinationBuilder::with_input_type(EventType::EventD),
1226 )
1227 .expect("should not fail to add destination");
1228
1229 blueprint
1230 }
1231
1232 fn blueprint_with_sources_and_destinations(source_ids: &[&str], destination_ids: &[&str]) -> TopologyBlueprint {
1237 let component_registry = ComponentRegistry::default();
1238 let mut blueprint = TopologyBlueprint::new("test", &component_registry);
1239
1240 for source_id in source_ids {
1241 blueprint
1242 .add_source(*source_id, TestSourceBuilder::default_output(EventType::EventD))
1243 .expect("should not fail to add source");
1244 }
1245
1246 for destination_id in destination_ids {
1247 blueprint
1248 .add_destination(
1249 *destination_id,
1250 TestDestinationBuilder::with_input_type(EventType::EventD),
1251 )
1252 .expect("should not fail to add destination");
1253 }
1254
1255 blueprint
1256 }
1257
1258 fn connected_pairs(blueprint: &TopologyBlueprint) -> Vec<(String, String)> {
1260 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1261 let outbound_edges = guard
1262 .as_ref()
1263 .expect("topology blueprint already initialized")
1264 .graph
1265 .get_outbound_directed_edges();
1266
1267 let mut pairs = Vec::new();
1268 for (from, outputs) in &outbound_edges {
1269 for targets in outputs.values() {
1270 for to in targets {
1271 pairs.push((from.component_id().to_string(), to.component_id().to_string()));
1272 }
1273 }
1274 }
1275 pairs.sort();
1276 pairs
1277 }
1278
1279 #[test]
1280 fn connect_components_in_order_errors_with_fewer_than_two_ids() {
1281 let mut blueprint = blueprint_with_components();
1282
1283 let result = blueprint.connect_components_in_order(Vec::<&str>::new()).map(|_| ());
1285 assert!(result.is_err());
1286
1287 let result = blueprint.connect_components_in_order(["source"]).map(|_| ());
1289 assert!(result.is_err());
1290
1291 assert!(connected_pairs(&blueprint).is_empty());
1293 }
1294
1295 #[test]
1296 fn connect_components_in_order_connects_pairwise_left_to_right() {
1297 let mut blueprint = blueprint_with_components();
1298
1299 blueprint
1300 .connect_components_in_order(["source", "transform", "destination"])
1301 .expect("should not fail to connect components in order");
1302
1303 assert_eq!(
1306 connected_pairs(&blueprint),
1307 vec![
1308 ("source".to_string(), "transform".to_string()),
1309 ("transform".to_string(), "destination".to_string()),
1310 ],
1311 );
1312 }
1313
1314 #[test]
1315 fn connect_component_one_to_many_fans_out() {
1316 let mut blueprint = blueprint_with_sources_and_destinations(&["source"], &["dest_a", "dest_b"]);
1319
1320 blueprint
1321 .connect_components("source", ["dest_a", "dest_b"])
1322 .expect("should not fail to connect component");
1323
1324 assert_eq!(
1325 connected_pairs(&blueprint),
1326 vec![
1327 ("source".to_string(), "dest_a".to_string()),
1328 ("source".to_string(), "dest_b".to_string()),
1329 ],
1330 );
1331 }
1332
1333 #[test]
1334 fn connect_component_many_to_one_fans_in() {
1335 let mut blueprint = blueprint_with_sources_and_destinations(&["source_a", "source_b"], &["dest"]);
1338
1339 blueprint
1340 .connect_components(["source_a", "source_b"], "dest")
1341 .expect("should not fail to connect component");
1342
1343 assert_eq!(
1344 connected_pairs(&blueprint),
1345 vec![
1346 ("source_a".to_string(), "dest".to_string()),
1347 ("source_b".to_string(), "dest".to_string()),
1348 ],
1349 );
1350 }
1351
1352 #[test]
1353 fn connect_component_many_to_many_creates_mesh() {
1354 let mut blueprint = blueprint_with_sources_and_destinations(&["source_a", "source_b"], &["dest_a", "dest_b"]);
1357
1358 blueprint
1359 .connect_components(["source_a", "source_b"], ["dest_a", "dest_b"])
1360 .expect("should not fail to connect component");
1361
1362 assert_eq!(
1363 connected_pairs(&blueprint),
1364 vec![
1365 ("source_a".to_string(), "dest_a".to_string()),
1366 ("source_a".to_string(), "dest_b".to_string()),
1367 ("source_b".to_string(), "dest_a".to_string()),
1368 ("source_b".to_string(), "dest_b".to_string()),
1369 ],
1370 );
1371 }
1372
1373 fn connected_blueprint() -> TopologyBlueprint {
1375 let mut blueprint = blueprint_with_components();
1376 blueprint
1377 .connect_components_in_order(["source", "transform", "destination"])
1378 .expect("should not fail to connect components");
1379 blueprint
1380 }
1381
1382 #[tokio::test]
1383 async fn topology_failure_shuts_down_supervisor() {
1384 let mut blueprint = connected_blueprint();
1388 blueprint
1389 .with_health_registry(HealthRegistry::new())
1390 .with_memory_limiter(MemoryLimiter::noop())
1391 .with_resource_registry(ResourceRegistry::new());
1392
1393 let result = run_blueprint_until_exit(
1394 blueprint,
1395 RestartStrategy::new(RestartMode::OneForOne, 0, Duration::from_secs(5)),
1396 )
1397 .await;
1398
1399 assert!(matches!(result, Err(SupervisorError::Shutdown)));
1400 }
1401
1402 #[tokio::test]
1403 async fn topology_cannot_be_initialized_more_than_once() {
1404 let mut blueprint = connected_blueprint();
1409 blueprint
1410 .with_health_registry(HealthRegistry::new())
1411 .with_memory_limiter(MemoryLimiter::noop())
1412 .with_resource_registry(ResourceRegistry::new());
1413
1414 let result = run_blueprint_until_exit(blueprint, RestartStrategy::default()).await;
1415
1416 assert!(matches!(result, Err(SupervisorError::FailedToInitialize { .. })));
1417 }
1418
1419 #[tokio::test]
1420 async fn topology_waits_for_environment_readiness_before_starting() {
1421 let gate_reached = Arc::new(AtomicUsize::new(0));
1428 let gate = {
1429 let gate_reached = Arc::clone(&gate_reached);
1430 async move {
1431 gate_reached.fetch_add(1, Ordering::SeqCst);
1432 std::future::pending::<()>().await;
1433 }
1434 };
1435
1436 let mut blueprint = connected_blueprint();
1437 blueprint
1438 .with_health_registry(HealthRegistry::new())
1439 .with_memory_limiter(MemoryLimiter::noop())
1440 .with_resource_registry(ResourceRegistry::new())
1441 .with_environment_readiness(gate);
1442
1443 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1444
1445 wait_until("the topology reached the readiness gate", || {
1448 gate_reached.load(Ordering::SeqCst) >= 1
1449 })
1450 .await;
1451 tx.send(()).expect("should send shutdown signal");
1452
1453 let result = join_topology(handle).await;
1454 assert!(result.is_ok(), "supervisor should shut down cleanly, got: {:?}", result);
1455 }
1456
1457 #[test]
1458 fn topology_ready_waits_for_registration_before_checking_readiness() {
1459 use tokio_test::{assert_pending, assert_ready, task::spawn};
1460
1461 let health_registry = HealthRegistry::new();
1462
1463 let mut other = health_registry
1466 .register_component(&SubsystemIdentifier::from_dotted("env_provider.workload.foo"))
1467 .expect("should register component");
1468 other.mark_ready();
1469
1470 let (registered_tx, registered_rx) = oneshot::channel();
1471 let topology_ready = TopologyReady {
1472 registered_rx,
1473 health_registry: health_registry.clone(),
1474 component_root: topology_identifier("primary"),
1475 };
1476
1477 let mut wait = spawn(topology_ready.wait());
1478
1479 assert_pending!(wait.poll());
1482
1483 let mut source = health_registry
1485 .register_component(&SubsystemIdentifier::from_dotted("topology.primary.sources.in"))
1486 .expect("should register component");
1487
1488 registered_tx.send(()).expect("receiver should be alive");
1491 assert_pending!(wait.poll());
1492
1493 source.mark_ready();
1495 assert!(assert_ready!(wait.poll()));
1496 }
1497
1498 #[tokio::test]
1499 async fn topology_ready_returns_false_when_torn_down_before_registration() {
1500 let health_registry = HealthRegistry::new();
1501
1502 let (registered_tx, registered_rx) = oneshot::channel::<()>();
1503 let topology_ready = TopologyReady {
1504 registered_rx,
1505 health_registry,
1506 component_root: topology_identifier("primary"),
1507 };
1508
1509 drop(registered_tx);
1512
1513 assert!(!topology_ready.wait().await);
1514 }
1515
1516 #[tokio::test]
1517 async fn topology_clean_shutdown_returns_ok() {
1518 let (blueprint, source_started) = long_running_blueprint(None);
1523 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1524
1525 wait_until("the source has started", || source_started.load(Ordering::SeqCst) == 1).await;
1528 tx.send(()).expect("should send shutdown signal");
1529
1530 let result = join_topology(handle).await;
1531 assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1532 }
1533
1534 #[tokio::test]
1535 async fn component_can_spawn_dynamic_child_via_spawn_handle() {
1536 let child_started = Arc::new(AtomicUsize::new(0));
1541 let (blueprint, _source_started) = long_running_blueprint(Some(Arc::clone(&child_started)));
1542 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1543
1544 wait_until("the dynamic child has started", || {
1546 child_started.load(Ordering::SeqCst) == 1
1547 })
1548 .await;
1549 assert_eq!(
1550 child_started.load(Ordering::SeqCst),
1551 1,
1552 "dynamic child should have started exactly once"
1553 );
1554
1555 tx.send(()).expect("should send shutdown signal");
1556 let result = join_topology(handle).await;
1557 assert!(result.is_ok(), "topology should shut down cleanly, got: {:?}", result);
1558 }
1559
1560 #[tokio::test]
1561 async fn forced_abort_count_survives_topology_boundary_to_root() {
1562 let (blueprint, destination_started) = stuck_destination_blueprint(Duration::from_millis(100));
1572 let (tx, handle) = spawn_supervised_blueprint(blueprint);
1573
1574 wait_until("the stuck destination has started", || {
1577 destination_started.load(Ordering::SeqCst) == 1
1578 })
1579 .await;
1580 tx.send(()).expect("should send shutdown signal");
1581
1582 let result = join_topology(handle).await;
1583 assert!(
1584 matches!(result, Err(SupervisorError::ShutdownTimedOut { aborted: 1 })),
1585 "a component that ignored shutdown must surface as an unclean shutdown with a count of 1, got {result:?}"
1586 );
1587 }
1588
1589 #[test]
1590 fn component_identity_is_byte_identical_across_subsystems() {
1591 for (topology_name, raw_id) in [("primary", "dsd_in"), ("secondary", "dsd_agg")] {
1596 let topology_root = topology_identifier(topology_name);
1598 let component_context = ComponentContext::source(&topology_root, ComponentId::try_from(raw_id).unwrap());
1599 let component_canonical_id = component_context.identity().to_string();
1600 let component_relative_id =
1601 get_component_relative_identifier(component_context.component_type(), component_context.component_id());
1602
1603 assert_eq!(
1604 component_canonical_id,
1605 format!("topology.{topology_name}.sources.{raw_id}")
1606 );
1607
1608 let registry = ComponentRegistry::default();
1611 registry
1612 .bounds_builder(&component_context.identity())
1613 .firm()
1614 .with_fixed_amount("marker", 1);
1615
1616 let bounds = registry.as_bounds();
1617 let mut node = &bounds;
1618 for segment in component_canonical_id.split('.') {
1619 node = node
1620 .subcomponents()
1621 .into_iter()
1622 .find_map(|(name, child)| (name.as_str() == segment).then_some(child))
1623 .expect("resource-accounting node path must match the canonical identity segment by segment");
1624 }
1625 assert_eq!(
1626 node.total_firm_limit_bytes(),
1627 1,
1628 "the resource-accounting node at the canonical path must hold the declared bounds"
1629 );
1630
1631 let topology_sup = Name::root(topology_root.to_string()).expect("topology name is non-empty");
1634 let process_name =
1635 Name::scoped(&topology_sup, component_relative_id.to_string()).expect("component name is non-empty");
1636 assert_eq!(
1637 &*process_name,
1638 component_canonical_id.as_str(),
1639 "per-component supervisor process name must match the canonical identity"
1640 );
1641 }
1642 }
1643
1644 #[test]
1645 fn recalculate_bounds_accounts_for_interconnect_and_event_buffer_memory() {
1646 let interconnect_capacity = 4usize;
1651 let mut blueprint = blueprint_with_components();
1652 blueprint.with_interconnect_capacity(NonZeroUsize::new(interconnect_capacity).unwrap());
1653
1654 let (sources, transforms, destinations, decoders) = (1usize, 1usize, 1usize, 0usize);
1656
1657 let total_interconnect_capacity = interconnect_capacity * (transforms + destinations);
1660 let expected_min = total_interconnect_capacity * std::mem::size_of::<EventsBuffer>();
1661
1662 let max_in_flight = ((transforms + destinations) * interconnect_capacity) + sources + decoders + transforms;
1665 let per_buffer =
1666 std::mem::size_of::<EventsBuffer>() + (std::mem::size_of::<Event>() * DEFAULT_EVENTS_BUFFER_CAPACITY);
1667 let expected_firm = expected_min + (max_in_flight * per_buffer);
1668
1669 let bounds = {
1670 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1671 guard
1672 .as_ref()
1673 .expect("topology blueprint already initialized")
1674 .component_registry
1675 .as_bounds()
1676 };
1677
1678 assert_eq!(
1679 bounds.total_minimum_required_bytes(),
1680 expected_min,
1681 "interconnect minimum bytes should be capacity * non-source components * size_of::<EventsBuffer>()"
1682 );
1683 assert_eq!(
1684 bounds.total_firm_limit_bytes(),
1685 expected_firm,
1686 "firm bytes should add the max in-flight event-buffer memory on top of the minimum"
1687 );
1688 }
1689
1690 #[test]
1691 fn worker_pool_configuration_defaults_to_dedicated() {
1692 let blueprint = blueprint_with_components();
1693 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1694 let config = &guard
1695 .as_ref()
1696 .expect("topology blueprint already initialized")
1697 .worker_pool_config;
1698 assert!(
1699 matches!(config, WorkerPoolConfiguration::Dedicated),
1700 "the default worker-pool configuration must be dedicated"
1701 );
1702 }
1703
1704 #[test]
1705 fn with_ambient_worker_pool_selects_ambient() {
1706 let mut blueprint = blueprint_with_components();
1707 blueprint.with_ambient_worker_pool();
1708
1709 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1710 let config = &guard
1711 .as_ref()
1712 .expect("topology blueprint already initialized")
1713 .worker_pool_config;
1714 assert!(matches!(config, WorkerPoolConfiguration::Ambient));
1715 }
1716
1717 #[tokio::test]
1718 async fn with_explicit_worker_pool_selects_explicit() {
1719 let mut blueprint = blueprint_with_components();
1720 blueprint.with_explicit_worker_pool(tokio::runtime::Handle::current());
1721
1722 let guard = blueprint.build_state.lock().expect("topology blueprint mutex poisoned");
1723 let config = &guard
1724 .as_ref()
1725 .expect("topology blueprint already initialized")
1726 .worker_pool_config;
1727 assert!(matches!(config, WorkerPoolConfiguration::Explicit(_)));
1728 }
1729}