From 3e9f5e91fd544c7081d23ad21a46299d3be195fe Mon Sep 17 00:00:00 2001 From: gouslu Date: Tue, 5 May 2026 20:15:46 -0700 Subject: [PATCH 1/7] feat(engine): wire extensions and capabilities into runtime pipeline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../exporters/azure_monitor_exporter/mod.rs | 3 +- .../src/exporters/geneva_exporter/mod.rs | 3 +- .../condense_attributes_processor/mod.rs | 14 +- .../resource_validator_processor/mod.rs | 14 +- .../controller/src/live_control_tests.rs | 2 + .../temporal_reaggregation_processor/main.rs | 1 + .../src/exporters/console_exporter/mod.rs | 3 +- .../src/exporters/error_exporter/mod.rs | 1 + .../src/exporters/noop_exporter/mod.rs | 3 +- .../src/exporters/otap_exporter/mod.rs | 3 +- .../src/exporters/otlp_grpc_exporter/mod.rs | 3 +- .../src/exporters/otlp_http_exporter/mod.rs | 1 + .../src/exporters/parquet_exporter/mod.rs | 3 +- .../src/exporters/perf_exporter/mod.rs | 3 +- .../src/exporters/topic_exporter/mod.rs | 84 +- .../processors/attributes_processor/mod.rs | 14 +- .../src/processors/batch_processor/mod.rs | 14 +- .../src/processors/content_router/mod.rs | 3 +- .../src/processors/debug_processor/mod.rs | 14 +- .../src/processors/delay_processor/mod.rs | 11 +- .../durable_buffer_processor/mod.rs | 5 + .../src/processors/fanout_processor/mod.rs | 3 +- .../src/processors/filter_processor/mod.rs | 14 +- .../processors/log_sampling_processor/mod.rs | 14 +- .../src/processors/retry_processor/mod.rs | 4 + .../src/processors/signal_type_router/mod.rs | 3 +- .../temporal_reaggregation_processor/mod.rs | 14 +- .../testing.rs | 1 + .../src/processors/transform_processor/mod.rs | 3 + .../src/receivers/fake_data_generator/mod.rs | 3 +- .../internal_telemetry_receiver/mod.rs | 3 +- .../src/receivers/otap_receiver/mod.rs | 3 +- .../src/receivers/otlp_receiver/mod.rs | 3 +- .../src/receivers/syslog_cef_receiver/mod.rs | 3 +- .../src/receivers/topic_receiver/mod.rs | 109 +- .../crates/engine-macros/src/capability.rs | 102 +- .../crates/engine-macros/src/lib.rs | 4 +- rust/otap-dataflow/crates/engine/Cargo.toml | 5 + .../crates/engine/src/capability/factory.rs | 18 +- .../crates/engine/src/capability/mod.rs | 6 +- .../engine/src/capability/no_op_stateful.rs | 76 + .../engine/src/capability/no_op_stateless.rs | 58 + .../src/capability/registry/capabilities.rs | 41 +- .../engine/src/capability/registry/entry.rs | 29 +- .../engine/src/capability/registry/tests.rs | 255 +- .../crates/engine/src/capability/tests.rs | 23 +- rust/otap-dataflow/crates/engine/src/error.rs | 28 + .../crates/engine/src/extension/builder.rs | 44 +- .../crates/engine/src/extension/tests.rs | 6 +- rust/otap-dataflow/crates/engine/src/lib.rs | 233 +- .../crates/engine/src/local/capability.rs | 5 +- .../crates/engine/src/runtime_pipeline.rs | 141 +- .../crates/engine/src/shared/capability.rs | 5 +- .../crates/engine/src/testing/exporter.rs | 11 +- .../crates/engine/tests/extension_e2e.rs | 3768 +++++++++++++++++ .../otap/tests/common/counting_exporter.rs | 3 +- .../otap/tests/common/flaky_exporter.rs | 3 +- .../otap/tests/topic_pipeline_flow_tests.rs | 4 + .../crates/validation/src/fanout_processor.rs | 3 +- .../validation/src/validation_exporter.rs | 3 +- .../docs/extension-system-architecture.md | 97 +- 61 files changed, 5034 insertions(+), 319 deletions(-) create mode 100644 rust/otap-dataflow/crates/engine/src/capability/no_op_stateful.rs create mode 100644 rust/otap-dataflow/crates/engine/src/capability/no_op_stateless.rs create mode 100644 rust/otap-dataflow/crates/engine/tests/extension_e2e.rs diff --git a/rust/otap-dataflow/crates/contrib-nodes/src/exporters/azure_monitor_exporter/mod.rs b/rust/otap-dataflow/crates/contrib-nodes/src/exporters/azure_monitor_exporter/mod.rs index 3171c09eb0..58f2156000 100644 --- a/rust/otap-dataflow/crates/contrib-nodes/src/exporters/azure_monitor_exporter/mod.rs +++ b/rust/otap-dataflow/crates/contrib-nodes/src/exporters/azure_monitor_exporter/mod.rs @@ -54,7 +54,8 @@ pub static AZURE_MONITOR_EXPORTER: ExporterFactory = ExporterFactory create: |pipeline_ctx: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { // Deserialize user config JSON into typed Config let cfg: Config = serde_json::from_value(node_config.config.clone()).map_err(|e| { otap_df_config::error::Error::InvalidUserConfig { diff --git a/rust/otap-dataflow/crates/contrib-nodes/src/exporters/geneva_exporter/mod.rs b/rust/otap-dataflow/crates/contrib-nodes/src/exporters/geneva_exporter/mod.rs index a3fd947e30..b2d29b9ddf 100644 --- a/rust/otap-dataflow/crates/contrib-nodes/src/exporters/geneva_exporter/mod.rs +++ b/rust/otap-dataflow/crates/contrib-nodes/src/exporters/geneva_exporter/mod.rs @@ -697,7 +697,8 @@ pub static GENEVA_EXPORTER: ExporterFactory = ExporterFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( GenevaExporter::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/contrib-nodes/src/processors/condense_attributes_processor/mod.rs b/rust/otap-dataflow/crates/contrib-nodes/src/processors/condense_attributes_processor/mod.rs index d88da0e67f..404f0fb4c7 100644 --- a/rust/otap-dataflow/crates/contrib-nodes/src/processors/condense_attributes_processor/mod.rs +++ b/rust/otap-dataflow/crates/contrib-nodes/src/processors/condense_attributes_processor/mod.rs @@ -199,12 +199,14 @@ pub fn create_condense_attributes_processor( pub static CONDENSE_ATTRIBUTES_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: CONDENSE_ATTRIBUTES_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_condense_attributes_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_condense_attributes_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: |config| Config::from_config(config).map(|_| ()), }; diff --git a/rust/otap-dataflow/crates/contrib-nodes/src/processors/resource_validator_processor/mod.rs b/rust/otap-dataflow/crates/contrib-nodes/src/processors/resource_validator_processor/mod.rs index f7866011da..1d27e5e49e 100644 --- a/rust/otap-dataflow/crates/contrib-nodes/src/processors/resource_validator_processor/mod.rs +++ b/rust/otap-dataflow/crates/contrib-nodes/src/processors/resource_validator_processor/mod.rs @@ -172,12 +172,14 @@ pub fn create_resource_validator_processor( pub static RESOURCE_VALIDATOR_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: RESOURCE_VALIDATOR_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_resource_validator_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_resource_validator_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: otap_df_config::validation::validate_typed_config::, }; diff --git a/rust/otap-dataflow/crates/controller/src/live_control_tests.rs b/rust/otap-dataflow/crates/controller/src/live_control_tests.rs index 4955e9b794..eaf392b519 100644 --- a/rust/otap-dataflow/crates/controller/src/live_control_tests.rs +++ b/rust/otap-dataflow/crates/controller/src/live_control_tests.rs @@ -43,6 +43,7 @@ fn test_receiver_create( _node: otap_df_engine::node::NodeId, _node_config: Arc, _receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, otap_df_config::error::Error> { panic!("test receiver factory should not be constructed") } @@ -52,6 +53,7 @@ fn test_exporter_create( _node: otap_df_engine::node::NodeId, _node_config: Arc, _exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, otap_df_config::error::Error> { panic!("test exporter factory should not be constructed") } diff --git a/rust/otap-dataflow/crates/core-nodes/benches/temporal_reaggregation_processor/main.rs b/rust/otap-dataflow/crates/core-nodes/benches/temporal_reaggregation_processor/main.rs index 839a1a1ce2..2d814423db 100644 --- a/rust/otap-dataflow/crates/core-nodes/benches/temporal_reaggregation_processor/main.rs +++ b/rust/otap-dataflow/crates/core-nodes/benches/temporal_reaggregation_processor/main.rs @@ -141,6 +141,7 @@ fn create_processor() -> ProcessorState { test_node("temporal_reaggregation_bench"), Arc::new(node_config), &config, + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("failed to create processor"); diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/console_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/console_exporter/mod.rs index 941c474044..de4e3a83d9 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/console_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/console_exporter/mod.rs @@ -79,7 +79,8 @@ pub static CONSOLE_EXPORTER: ExporterFactory = ExporterFactory { create: |_pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { let config: ConsoleExporterConfig = serde_json::from_value(node_config.config.clone()) .map_err(|e| ConfigError::InvalidUserConfig { error: format!("Failed to parse console exporter config: {}", e), diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/error_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/error_exporter/mod.rs index bd4b9c08f2..70effd9cb7 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/error_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/error_exporter/mod.rs @@ -51,6 +51,7 @@ impl ErrorExporter { node: NodeId, node_config: Arc, exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, otap_df_config::error::Error> { let config: ErrorExporterConfig = serde_json::from_value(node_config.config.clone()) .map_err(|e| otap_df_config::error::Error::InvalidUserConfig { diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/noop_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/noop_exporter/mod.rs index 4364e34160..524a2a4b00 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/noop_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/noop_exporter/mod.rs @@ -32,7 +32,8 @@ pub static NOOP_EXPORTER: ExporterFactory = ExporterFactory { create: |_pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( NoopExporter {}, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/otap_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/otap_exporter/mod.rs index f7a434873f..4036c79f81 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/otap_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/otap_exporter/mod.rs @@ -159,7 +159,8 @@ pub static OTAP_EXPORTER: ExporterFactory = ExporterFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( OTAPExporter::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_grpc_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_grpc_exporter/mod.rs index a4342f81c5..c436cc3798 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_grpc_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_grpc_exporter/mod.rs @@ -84,7 +84,8 @@ pub static OTLP_EXPORTER: ExporterFactory = ExporterFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( OTLPExporter::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_http_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_http_exporter/mod.rs index 0db454dcc9..ba45eacaae 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_http_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/otlp_http_exporter/mod.rs @@ -90,6 +90,7 @@ fn factory_create( node: NodeId, node_config: Arc, exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, ConfigError> { Ok(ExporterWrapper::local( OtlpHttpExporter::from_config(pipeline, &node_config.config)?, diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/parquet_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/parquet_exporter/mod.rs index 248edf75fc..eedc56fba1 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/parquet_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/parquet_exporter/mod.rs @@ -81,7 +81,8 @@ pub static PARQUET_EXPORTER: ExporterFactory = ExporterFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( ParquetExporter::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/perf_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/perf_exporter/mod.rs index 2503116ee6..35305433fc 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/perf_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/perf_exporter/mod.rs @@ -70,7 +70,8 @@ pub static PERF_EXPORTER: ExporterFactory = ExporterFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( PerfExporter::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/exporters/topic_exporter/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/exporters/topic_exporter/mod.rs index 7c522cad97..7a1039a3f5 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/exporters/topic_exporter/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/exporters/topic_exporter/mod.rs @@ -113,52 +113,54 @@ enum BlockedPublishCompletion { /// Declares the topic exporter as a local exporter factory. #[allow(unsafe_code)] #[distributed_slice(OTAP_EXPORTER_FACTORIES)] -pub static TOPIC_EXPORTER: ExporterFactory = - ExporterFactory { - name: TOPIC_EXPORTER_URN, - create: |pipeline: PipelineContext, - node: NodeId, - node_config: Arc, - exporter_config: &ExporterConfig| { - let config = TopicExporter::parse_config(&node_config.config)?; - let topic_set = pipeline.topic_set::().ok_or_else(|| { - ConfigError::InvalidUserConfig { +pub static TOPIC_EXPORTER: ExporterFactory = ExporterFactory { + name: TOPIC_EXPORTER_URN, + create: |pipeline: PipelineContext, + node: NodeId, + node_config: Arc, + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + let config = TopicExporter::parse_config(&node_config.config)?; + let topic_set = + pipeline + .topic_set::() + .ok_or_else(|| ConfigError::InvalidUserConfig { error: "Topic set is not available in pipeline context".to_owned(), - } - })?; - let topic_binding = topic_set.get_required(&config.topic).map_err(|_| { - ConfigError::InvalidUserConfig { + })?; + let topic_binding = + topic_set + .get_required(&config.topic) + .map_err(|_| ConfigError::InvalidUserConfig { error: format!( "Unknown topic `{}` for topic exporter (pipeline `{}`/`{}`)", config.topic, pipeline.pipeline_group_id(), pipeline.pipeline_id(), ), - } - })?; - let queue_on_full = config - .queue_on_full - .clone() - .unwrap_or_else(|| topic_binding.default_queue_on_full()); - let ack_propagation_mode = topic_binding.default_ack_propagation_mode(); - let metrics = pipeline - .register_metrics_with_topic::(topic_binding.name().into()); - let topic = topic_binding.into_handle(); - Ok(ExporterWrapper::local( - TopicExporter { - topic, - queue_on_full, - ack_propagation_mode, - metrics, - }, - node, - node_config, - exporter_config, - )) - }, - wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, - validate_config: |config| TopicExporter::parse_config(config).map(|_| ()), - }; + })?; + let queue_on_full = config + .queue_on_full + .clone() + .unwrap_or_else(|| topic_binding.default_queue_on_full()); + let ack_propagation_mode = topic_binding.default_ack_propagation_mode(); + let metrics = pipeline + .register_metrics_with_topic::(topic_binding.name().into()); + let topic = topic_binding.into_handle(); + Ok(ExporterWrapper::local( + TopicExporter { + topic, + queue_on_full, + ack_propagation_mode, + metrics, + }, + node, + node_config, + exporter_config, + )) + }, + wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, + validate_config: |config| TopicExporter::parse_config(config).map(|_| ()), +}; impl TopicExporter { /// Parses and validates topic exporter configuration. @@ -689,6 +691,7 @@ mod tests { exporter_node.clone(), Arc::new(exporter_user_cfg), &ExporterConfig::new("topic_exporter"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic exporter should be created"); @@ -830,6 +833,7 @@ mod tests { exporter_node.clone(), Arc::new(exporter_user_cfg), &ExporterConfig::new("topic_exporter"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic exporter should be created"); @@ -995,6 +999,7 @@ mod tests { exporter_node.clone(), Arc::new(exporter_user_cfg), &ExporterConfig::new("topic_exporter"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic exporter should be created"); @@ -1173,6 +1178,7 @@ mod tests { exporter_node.clone(), Arc::new(exporter_user_cfg), &ExporterConfig::new("topic_exporter"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic exporter should be created"); diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/attributes_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/attributes_processor/mod.rs index e48c3d43e3..d6af341849 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/attributes_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/attributes_processor/mod.rs @@ -468,12 +468,14 @@ pub fn create_attributes_processor( pub static ATTRIBUTES_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: ATTRIBUTES_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_attributes_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_attributes_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: otap_df_config::validation::validate_typed_config::, }; diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/batch_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/batch_processor/mod.rs index 9bee31cb45..f22a5a504b 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/batch_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/batch_processor/mod.rs @@ -1577,12 +1577,14 @@ where pub static OTAP_BATCH_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: OTAP_BATCH_PROCESSOR_URN, - create: |pipeline_ctx: otap_df_engine::context::PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_otap_batch_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: otap_df_engine::context::PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_otap_batch_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: otap_df_config::validation::validate_typed_config::, }; diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/content_router/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/content_router/mod.rs index 7882d0b561..dfb8ebdd05 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/content_router/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/content_router/mod.rs @@ -959,7 +959,8 @@ pub static CONTENT_ROUTER_FACTORY: ProcessorFactory = ProcessorFactor create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - proc_cfg: &ProcessorConfig| { + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { let router_config: ContentRouterConfig = serde_json::from_value(node_config.config.clone()) .map_err(|e| ConfigError::InvalidUserConfig { error: format!("Failed to parse ContentRouter configuration: {e}"), diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/debug_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/debug_processor/mod.rs index 5b3dd3a82c..540f5ec5d3 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/debug_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/debug_processor/mod.rs @@ -85,12 +85,14 @@ pub fn create_debug_processor( pub static DEBUG_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: DEBUG_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_debug_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_debug_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: otap_df_config::validation::validate_typed_config::, }; diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/delay_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/delay_processor/mod.rs index 62f93859b6..0cf7642283 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/delay_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/delay_processor/mod.rs @@ -63,6 +63,7 @@ pub fn create_delay_processor( node: NodeId, node_config: Arc, processor_config: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, ConfigError> { let config: DelayConfig = serde_json::from_value(node_config.config.clone()).map_err(|e| { ConfigError::InvalidUserConfig { @@ -153,8 +154,14 @@ mod tests { let mut node_config = NodeUserConfig::new_processor_config(DELAY_PROCESSOR_URN); node_config.config = json!({ "delay": "1ms" }); - let proc = create_delay_processor(pipeline_ctx, node, Arc::new(node_config), rt.config()) - .expect("create processor"); + let proc = create_delay_processor( + pipeline_ctx, + node, + Arc::new(node_config), + rt.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), + ) + .expect("create processor"); let phase = rt.set_processor(proc); diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/durable_buffer_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/durable_buffer_processor/mod.rs index f0b0fb61bd..50a0efc2f4 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/durable_buffer_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/durable_buffer_processor/mod.rs @@ -1918,6 +1918,7 @@ pub fn create_durable_buffer( node: NodeId, node_config: Arc, processor_config: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, ConfigError> { let config: DurableBufferConfig = serde_json::from_value(node_config.config.clone()).map_err(|e| { @@ -2032,6 +2033,7 @@ mod tests { test_node("durable-buffer-retry-wakeup"), Arc::new(node_config), &ProcessorConfig::new("durable-buffer-retry-wakeup"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create durable buffer"); @@ -2121,6 +2123,7 @@ mod tests { test_node("durable-buffer-unknown-wakeup"), Arc::new(node_config), &ProcessorConfig::with_channel_capacities("durable-buffer-unknown-wakeup", 1, 100), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create durable buffer"); @@ -2222,6 +2225,7 @@ mod tests { test_node("durable-buffer-shared-retry-wakeup"), Arc::new(node_config), &ProcessorConfig::with_channel_capacities("durable-buffer-shared-retry-wakeup", 1, 100), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create durable buffer"); @@ -2320,6 +2324,7 @@ mod tests { test_node("durable-buffer-shutdown-drain-deferred"), Arc::new(node_config), &ProcessorConfig::new("durable-buffer-shutdown-drain-deferred"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create durable buffer"); diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/fanout_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/fanout_processor/mod.rs index 088aef30df..d63d48011b 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/fanout_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/fanout_processor/mod.rs @@ -1186,7 +1186,8 @@ pub static FANOUT_PROCESSOR_FACTORY: ProcessorFactory = ProcessorFact create: |pipeline_ctx: PipelineContext, node: NodeId, node_config: Arc, - proc_cfg: &ProcessorConfig| { + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { create_fanout_processor(pipeline_ctx, node, node_config, proc_cfg) }, wiring_contract: otap_df_engine::wiring_contract::WiringContract { diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/filter_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/filter_processor/mod.rs index 090c164af1..75fe8ca9bd 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/filter_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/filter_processor/mod.rs @@ -66,12 +66,14 @@ pub fn create_filter_processor( pub static FILTER_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: FILTER_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_filter_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_filter_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: otap_df_config::validation::validate_typed_config::, }; diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/log_sampling_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/log_sampling_processor/mod.rs index 24cc237a1c..756c79e3ac 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/log_sampling_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/log_sampling_processor/mod.rs @@ -48,12 +48,14 @@ const LOG_SAMPLING_PROCESSOR_URN: &str = "urn:otel:processor:log_sampling"; static LOG_SAMPLING_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: LOG_SAMPLING_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_log_sampling_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_log_sampling_processor(pipeline_ctx, node, node_config, proc_cfg) + }, validate_config: otap_df_config::validation::validate_typed_config::, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, }; diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/retry_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/retry_processor/mod.rs index a95f9ad450..327097d8bd 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/retry_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/retry_processor/mod.rs @@ -350,6 +350,7 @@ pub fn create_retry_processor( node: NodeId, node_config: Arc, processor_config: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, ConfigError> { let config: RetryConfig = serde_json::from_value(node_config.config.clone()).map_err(|e| { ConfigError::InvalidUserConfig { @@ -858,6 +859,7 @@ mod test { node, Arc::new(node_config), rt.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create processor"); @@ -932,6 +934,7 @@ mod test { node, Arc::new(node_config), rt.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create processor"); @@ -1007,6 +1010,7 @@ mod test { node, Arc::new(node_config), rt.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("create processor"); diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/signal_type_router/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/signal_type_router/mod.rs index 3b561dc46f..7f663d0513 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/signal_type_router/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/signal_type_router/mod.rs @@ -682,7 +682,8 @@ pub static SIGNAL_TYPE_ROUTER_FACTORY: ProcessorFactory = ProcessorFa create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - proc_cfg: &ProcessorConfig| { + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { // Deserialize the (currently empty) router configuration let router_config: SignalTypeRouterConfig = serde_json::from_value(node_config.config.clone()).map_err(|e| { diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/mod.rs index 50b323f270..e936e3343c 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/mod.rs @@ -125,12 +125,14 @@ pub const TEMPORAL_REAGGREGATION_PROCESSOR_URN: &str = "urn:otel:processor:tempo pub static TEMPORAL_REAGGREGATION_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory = otap_df_engine::ProcessorFactory { name: TEMPORAL_REAGGREGATION_PROCESSOR_URN, - create: |pipeline_ctx: PipelineContext, - node: NodeId, - node_config: Arc, - proc_cfg: &ProcessorConfig| { - create_temporal_reaggregation_processor(pipeline_ctx, node, node_config, proc_cfg) - }, + create: + |pipeline_ctx: PipelineContext, + node: NodeId, + node_config: Arc, + proc_cfg: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_temporal_reaggregation_processor(pipeline_ctx, node, node_config, proc_cfg) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: otap_df_config::validation::validate_typed_config::, }; diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/testing.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/testing.rs index 93858aecd3..9bdc4bf44b 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/testing.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/temporal_reaggregation_processor/testing.rs @@ -271,6 +271,7 @@ pub(super) fn try_create_processor( node, Arc::new(node_config), rt.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .map(|proc| (rt, proc)) } diff --git a/rust/otap-dataflow/crates/core-nodes/src/processors/transform_processor/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/processors/transform_processor/mod.rs index 49709cad5f..d7f4cf8ed1 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/processors/transform_processor/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/processors/transform_processor/mod.rs @@ -341,6 +341,7 @@ fn create_transform_processor( node_id: NodeId, user_config: Arc, processor_config: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, ConfigError> { let processor = TransformProcessor::from_config(&pipeline_ctx, &user_config.config)?; Ok(ProcessorWrapper::local( @@ -561,6 +562,7 @@ mod test { node_id, Arc::new(node_config), runtime.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), ) } @@ -1355,6 +1357,7 @@ mod test { node_id, Arc::new(node_config), runtime.config(), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("created processor"); diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/fake_data_generator/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/fake_data_generator/mod.rs index aa6f65e2e4..70adb4ab6b 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/fake_data_generator/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/fake_data_generator/mod.rs @@ -94,7 +94,8 @@ pub static OTAP_FAKE_DATA_GENERATOR: ReceiverFactory = ReceiverFactor create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - receiver_config: &ReceiverConfig| { + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ReceiverWrapper::local( FakeGeneratorReceiver::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/internal_telemetry_receiver/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/internal_telemetry_receiver/mod.rs index 8f10d6f8a2..2ea705f121 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/internal_telemetry_receiver/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/internal_telemetry_receiver/mod.rs @@ -55,7 +55,8 @@ pub static INTERNAL_TELEMETRY_RECEIVER: ReceiverFactory = ReceiverFac create: |mut pipeline: PipelineContext, node: NodeId, node_config: Arc, - receiver_config: &ReceiverConfig| { + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { // Get internal telemetry settings from the pipeline context let internal_telemetry = pipeline.take_internal_telemetry().ok_or_else(|| { otap_df_config::error::Error::InvalidUserConfig { diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/otap_receiver/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/otap_receiver/mod.rs index c37ba4582f..bb261e5788 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/otap_receiver/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/otap_receiver/mod.rs @@ -125,7 +125,8 @@ pub static OTAP_RECEIVER: ReceiverFactory = ReceiverFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - receiver_config: &ReceiverConfig| { + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ReceiverWrapper::shared( OTAPReceiver::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/otlp_receiver/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/otlp_receiver/mod.rs index bac50923a9..e8841280f9 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/otlp_receiver/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/otlp_receiver/mod.rs @@ -199,7 +199,8 @@ pub static OTLP_RECEIVER: ReceiverFactory = ReceiverFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - receiver_config: &ReceiverConfig| { + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { let mut receiver = OTLPReceiver::from_config(pipeline, &node_config.config)?; receiver.tune_max_concurrent_requests(receiver_config.output_pdata_channel.capacity); diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/syslog_cef_receiver/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/syslog_cef_receiver/mod.rs index 22652ac687..fd743b503d 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/syslog_cef_receiver/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/syslog_cef_receiver/mod.rs @@ -269,7 +269,8 @@ pub static SYSLOG_CEF_RECEIVER: ReceiverFactory = ReceiverFactory { create: |pipeline: PipelineContext, node: NodeId, node_config: Arc, - receiver_config: &ReceiverConfig| { + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ReceiverWrapper::local( SyslogCefReceiver::from_config(pipeline, &node_config.config)?, node, diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/topic_receiver/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/topic_receiver/mod.rs index a770b9ff9e..756f0678a6 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/topic_receiver/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/topic_receiver/mod.rs @@ -148,65 +148,67 @@ struct PendingForward { /// Declares the topic receiver as a local receiver factory. #[allow(unsafe_code)] #[distributed_slice(OTAP_RECEIVER_FACTORIES)] -pub static TOPIC_RECEIVER: ReceiverFactory = - ReceiverFactory { - name: TOPIC_RECEIVER_URN, - create: |pipeline: PipelineContext, - node: NodeId, - node_config: Arc, - receiver_config: &ReceiverConfig| { - let config = TopicReceiver::parse_config(&node_config.config)?; - let topic_set = pipeline.topic_set::().ok_or_else(|| { - ConfigError::InvalidUserConfig { +pub static TOPIC_RECEIVER: ReceiverFactory = ReceiverFactory { + name: TOPIC_RECEIVER_URN, + create: |pipeline: PipelineContext, + node: NodeId, + node_config: Arc, + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + let config = TopicReceiver::parse_config(&node_config.config)?; + let topic_set = + pipeline + .topic_set::() + .ok_or_else(|| ConfigError::InvalidUserConfig { error: "Topic set is not available in pipeline context".to_owned(), - } - })?; - let topic_binding = topic_set.get_required(&config.topic).map_err(|_| { - ConfigError::InvalidUserConfig { + })?; + let topic_binding = + topic_set + .get_required(&config.topic) + .map_err(|_| ConfigError::InvalidUserConfig { error: format!( "Unknown topic `{}` for topic receiver (pipeline `{}`/`{}`)", config.topic, pipeline.pipeline_group_id(), pipeline.pipeline_id(), ), - } - })?; - let mode = match &config.subscription { - TopicSubscriptionConfig::Broadcast {} => SubscriptionMode::Broadcast, - TopicSubscriptionConfig::Balanced { group } => SubscriptionMode::Balanced { - group: group.clone(), - }, - }; - let subscription = topic_binding - .subscribe(mode, SubscriberOptions::default()) - .map_err(|e| ConfigError::InvalidUserConfig { - error: format!( - "Failed to subscribe topic receiver to `{}`: {e}", - config.topic - ), })?; - let ack_propagation_mode = topic_binding.default_ack_propagation_mode(); - let broadcast_on_lag = - matches!(&config.subscription, TopicSubscriptionConfig::Broadcast {}) - .then(|| topic_binding.broadcast_on_lag_policy()); - let metrics = pipeline - .register_metrics_with_topic::(topic_binding.name().into()); - Ok(ReceiverWrapper::local( - TopicReceiver { - config, - subscription, - ack_propagation_mode, - broadcast_on_lag, - metrics, - }, - node, - node_config, - receiver_config, - )) - }, - wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, - validate_config: |config| TopicReceiver::parse_config(config).map(|_| ()), - }; + let mode = match &config.subscription { + TopicSubscriptionConfig::Broadcast {} => SubscriptionMode::Broadcast, + TopicSubscriptionConfig::Balanced { group } => SubscriptionMode::Balanced { + group: group.clone(), + }, + }; + let subscription = topic_binding + .subscribe(mode, SubscriberOptions::default()) + .map_err(|e| ConfigError::InvalidUserConfig { + error: format!( + "Failed to subscribe topic receiver to `{}`: {e}", + config.topic + ), + })?; + let ack_propagation_mode = topic_binding.default_ack_propagation_mode(); + let broadcast_on_lag = + matches!(&config.subscription, TopicSubscriptionConfig::Broadcast {}) + .then(|| topic_binding.broadcast_on_lag_policy()); + let metrics = pipeline + .register_metrics_with_topic::(topic_binding.name().into()); + Ok(ReceiverWrapper::local( + TopicReceiver { + config, + subscription, + ack_propagation_mode, + broadcast_on_lag, + metrics, + }, + node, + node_config, + receiver_config, + )) + }, + wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, + validate_config: |config| TopicReceiver::parse_config(config).map(|_| ()), +}; impl TopicReceiver { /// Parses and validates topic receiver configuration. @@ -859,6 +861,7 @@ mod tests { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); @@ -958,6 +961,7 @@ mod tests { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); @@ -1059,6 +1063,7 @@ mod tests { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); @@ -1152,6 +1157,7 @@ mod tests { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); @@ -1272,6 +1278,7 @@ mod tests { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); diff --git a/rust/otap-dataflow/crates/engine-macros/src/capability.rs b/rust/otap-dataflow/crates/engine-macros/src/capability.rs index 95453174b2..fda5424de6 100644 --- a/rust/otap-dataflow/crates/engine-macros/src/capability.rs +++ b/rust/otap-dataflow/crates/engine-macros/src/capability.rs @@ -17,7 +17,8 @@ //! //! # Supported //! -//! - Methods with `&self` receiver (sync and async) +//! - Methods with `&self` or `&mut self` receivers (sync and async; +//! explicit lifetimes like `&'a self` are also accepted) //! - Method-level generics, lifetimes, and where clauses //! - Default method bodies (preserved in generated local/shared traits) //! - Doc attributes on the trait (propagated to generated traits) @@ -50,6 +51,17 @@ //! registry entry impossible. //! - **Associated constants** — same fundamental issue as associated types. //! +//! # Receiver shapes (no macro-side validation) +//! +//! `&self` and `&mut self` are the supported receiver shapes for +//! `#[capability]` methods. Other shapes (consuming `self`, arbitrary +//! self types like `self: Box`, methods with no `self` +//! receiver) are not validated by the macro — Rust's object-safety +//! checks at the use sites of `dyn local::Trait` / `dyn shared::Trait`, +//! and the generated `SharedAsLocal` adapter, will reject anything +//! the system cannot support, with clearer error messages than the +//! macro could synthesize. +//! //! # Generated code paths //! //! The macro generates `crate::capability::*` paths, so it must be invoked @@ -60,7 +72,7 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote}; use syn::{ - Ident, ItemTrait, LitStr, Meta, TraitItem, TraitItemFn, + FnArg, Ident, ItemTrait, LitStr, Meta, TraitItem, TraitItemFn, parse::{Parse, ParseStream}, punctuated::Punctuated, token::Comma, @@ -165,6 +177,14 @@ fn validate_trait(trait_item: &ItemTrait) -> Result<(), TokenStream> { ) .to_compile_error()); } + TraitItem::Fn(_) => { + // No macro-side receiver validation. Object-safety + // and adapter delegation are enforced by the Rust + // compiler at the use sites of `dyn local::Trait` / + // `dyn shared::Trait` and inside the generated + // `SharedAsLocal` adapter, which gives clearer + // error messages than what the macro could emit. + } _ => {} } } @@ -247,7 +267,7 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> .inputs .iter() .filter_map(|arg| { - if let syn::FnArg::Typed(pat_type) = arg { + if let FnArg::Typed(pat_type) = arg { if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { Some(&pat_ident.ident) } else { @@ -365,14 +385,14 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> let adapt_as_local: fn( ::std::boxed::Box, - ) -> ::std::rc::Rc = |erased| { + ) -> ::std::boxed::Box = |erased| { let shared: ::std::boxed::Box<::std::boxed::Box> = erased .downcast() .expect("shared_entry produce closure returned wrong envelope"); - let rc_local = <#trait_name as crate::capability::ExtensionCapability>:: + let boxed_local = <#trait_name as crate::capability::ExtensionCapability>:: wrap_shared_as_local(*shared); - ::std::rc::Rc::new(rc_local) as ::std::rc::Rc + ::std::boxed::Box::new(boxed_local) as ::std::boxed::Box }; crate::capability::registry::SharedCapabilityEntry::new( @@ -387,9 +407,9 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> /// object. /// /// The entry's produce closure calls the stored instance - /// factory, downcasts the erased `Rc` to `Rc`, - /// coerces to `Rc`, and re-erases - /// under the double-`Rc` envelope expected by the registry. + /// factory, downcasts the erased `Box` to `Box`, + /// coerces to `Box`, and re-erases + /// under the double-`Box` envelope expected by the registry. #[allow(non_snake_case, clippy::missing_errors_doc)] #vis fn local_entry( extension_id: ::otap_df_config::ExtensionId, @@ -398,13 +418,13 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> where E: local::#trait_name + 'static, { - let produce = move || -> ::std::rc::Rc { + let produce = move || -> ::std::boxed::Box { let erased = factory.produce(); - let concrete: ::std::rc::Rc = erased + let concrete: ::std::boxed::Box = erased .downcast() .expect("instance_factory produced wrong type for capability"); - let local: ::std::rc::Rc = concrete; - ::std::rc::Rc::new(local) as ::std::rc::Rc + let local: ::std::boxed::Box = concrete; + ::std::boxed::Box::new(local) as ::std::boxed::Box }; crate::capability::registry::LocalCapabilityEntry::new(extension_id, produce) @@ -426,9 +446,9 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> fn wrap_shared_as_local( shared: ::std::boxed::Box, - ) -> ::std::rc::Rc { + ) -> ::std::boxed::Box { let adapter = #shared_as_local_name(shared); - ::std::rc::Rc::new(adapter) + ::std::boxed::Box::new(adapter) } } @@ -436,6 +456,11 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> // slice at link time, so the engine can enumerate all capabilities // compiled into the binary (by name, description, and TypeId) without // needing an explicit registration call. + // + // `#[allow(unsafe_code)]` is required because `linkme::distributed_slice` + // emits a static with `#[link_section = "..."]`, which the engine + // crate's lints (`-D unsafe-code`) would otherwise reject. + #[allow(unsafe_code)] #[::linkme::distributed_slice(crate::capability::KNOWN_CAPABILITIES)] #[linkme(crate = ::linkme)] static #known_cap_static: crate::capability::KnownCapability = @@ -446,3 +471,50 @@ pub(crate) fn expand_capability(args: CapabilityArgs, trait_item: ItemTrait) -> }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Parse a trait body and run it through `validate_trait`, returning the + /// error message text on rejection or `None` on success. + fn validate(src: &str) -> Option { + let trait_item: ItemTrait = syn::parse_str(src).expect("parse trait"); + validate_trait(&trait_item).err().map(|ts| ts.to_string()) + } + + #[test] + fn accepts_ref_self() { + assert!(validate("trait Cap { fn get(&self) -> u32; }").is_none()); + } + + #[test] + fn accepts_ref_self_with_lifetime() { + assert!(validate("trait Cap { fn get<'a>(&'a self) -> &'a str; }").is_none()); + } + + #[test] + fn accepts_async_ref_self() { + assert!(validate("trait Cap { async fn get(&self) -> u32; }").is_none()); + } + + #[test] + fn accepts_default_method_body() { + assert!(validate("trait Cap { fn get(&self) -> u32 { 0 } }").is_none()); + } + + #[test] + fn accepts_mut_self_reference() { + assert!(validate("trait Cap { fn set(&mut self); }").is_none()); + } + + #[test] + fn accepts_mut_self_reference_with_lifetime() { + assert!(validate("trait Cap { fn set<'a>(&'a mut self); }").is_none()); + } + + #[test] + fn accepts_async_mut_self() { + assert!(validate("trait Cap { async fn set(&mut self); }").is_none()); + } +} diff --git a/rust/otap-dataflow/crates/engine-macros/src/lib.rs b/rust/otap-dataflow/crates/engine-macros/src/lib.rs index cffbae3ca4..586e336d81 100644 --- a/rust/otap-dataflow/crates/engine-macros/src/lib.rs +++ b/rust/otap-dataflow/crates/engine-macros/src/lib.rs @@ -103,7 +103,7 @@ pub fn pipeline_factory(args: TokenStream, input: TokenStream) -> TokenStream { /// const NAME: &'static str = "bearer_token_provider"; /// type Local = dyn local::BearerTokenProvider; /// type Shared = dyn shared::BearerTokenProvider; -/// fn wrap_shared_as_local(...) -> Rc { /* wraps in adapter */ } +/// fn wrap_shared_as_local(...) -> Box { /* wraps in adapter */ } /// } /// /// // 6. KNOWN_CAPABILITIES entry — link-time registration for config validation. @@ -121,7 +121,7 @@ pub fn pipeline_factory(args: TokenStream, input: TokenStream) -> TokenStream { /// registration struct: /// /// ```rust,ignore -/// // Local consumer — returns Rc +/// // Local consumer — returns Box /// let auth = capabilities.require_local::()?; /// /// // Shared consumer — returns Box diff --git a/rust/otap-dataflow/crates/engine/Cargo.toml b/rust/otap-dataflow/crates/engine/Cargo.toml index 2894a6fdd9..6aa82ddfbc 100644 --- a/rust/otap-dataflow/crates/engine/Cargo.toml +++ b/rust/otap-dataflow/crates/engine/Cargo.toml @@ -22,6 +22,7 @@ test-utils = [] otap-df-channel = { workspace = true } otap-df-pdata = { workspace = true } otap-df-config = { workspace = true } +otap-df-engine-macros = { workspace = true } otap-df-state = { workspace = true } otap-df-telemetry = { workspace = true } otap-df-telemetry-macros = { workspace = true } @@ -53,6 +54,10 @@ tikv-jemalloc-sys = { workspace = true, optional = true } [dev-dependencies] otap-df-engine = { workspace = true, features = ["test-utils"] } +async-trait = { workspace = true } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "time"] } [target.'cfg(not(windows))'.dev-dependencies] tikv-jemallocator = { workspace = true } diff --git a/rust/otap-dataflow/crates/engine/src/capability/factory.rs b/rust/otap-dataflow/crates/engine/src/capability/factory.rs index 34b1136478..521c6d2139 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/factory.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/factory.rs @@ -94,6 +94,14 @@ impl Clone for SharedInstanceFactory { /// Produces instances of a local (!Send) extension's concrete type for /// capability consumers. See [`SharedInstanceFactory`] for background. /// +/// The instance policy chosen at the builder (`.cloned(...)` vs +/// `.constructed(...)`) is baked into the stored closure. Both policies +/// mint a fresh `Box` per call, mirroring the shared-side contract: +/// - **Cloned** — the closure captures a prototype `E: Clone` +/// and returns `Box::new(e.clone())` on each call. +/// - **Constructed** — the closure captures the user-supplied +/// `Fn() -> E` and boxes its output on each call. +/// /// `LocalInstanceFactory` is [`Clone`] for the same reason as its /// shared counterpart. pub struct LocalInstanceFactory { @@ -102,13 +110,13 @@ pub struct LocalInstanceFactory { /// Object-safe `Fn + Clone` helper for [`LocalInstanceFactory`]. #[doc(hidden)] -pub trait LocalFnClone: Fn() -> std::rc::Rc { +pub trait LocalFnClone: Fn() -> Box { fn clone_box(&self) -> Box; } impl LocalFnClone for F where - F: Fn() -> std::rc::Rc + Clone + 'static, + F: Fn() -> Box + Clone + 'static, { fn clone_box(&self) -> Box { Box::new(self.clone()) @@ -120,16 +128,16 @@ impl LocalInstanceFactory { #[must_use] pub fn new(produce: F) -> Self where - F: Fn() -> std::rc::Rc + Clone + 'static, + F: Fn() -> Box + Clone + 'static, { LocalInstanceFactory { produce: Box::new(produce), } } - /// Produce an instance as `Rc`. + /// Produce a fresh instance as `Box`. #[must_use] - pub fn produce(&self) -> std::rc::Rc { + pub fn produce(&self) -> Box { (self.produce)() } } diff --git a/rust/otap-dataflow/crates/engine/src/capability/mod.rs b/rust/otap-dataflow/crates/engine/src/capability/mod.rs index 477dcf0269..20a8a09a76 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/mod.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/mod.rs @@ -13,6 +13,8 @@ //! [`shared::capability`](crate::shared::capability). pub mod factory; +pub mod no_op_stateful; +pub mod no_op_stateless; pub mod registry; pub use factory::{LocalInstanceFactory, SharedInstanceFactory}; @@ -89,10 +91,10 @@ pub trait ExtensionCapability: private::Sealed + 'static { /// Authors normally don't implement this by hand; the macro handles /// it. If you are hand-rolling an `ExtensionCapability` impl (only /// needed inside the engine crate for testing), return - /// `Rc::new(YourAdapter(shared))`. + /// `Box::new(YourAdapter(shared))`. /// /// [`Capabilities::require_shared`]: registry::Capabilities::require_shared - fn wrap_shared_as_local(shared: Box) -> std::rc::Rc; + fn wrap_shared_as_local(shared: Box) -> Box; } /// Re-export for use by the `#[capability]` proc macro's generated code. diff --git a/rust/otap-dataflow/crates/engine/src/capability/no_op_stateful.rs b/rust/otap-dataflow/crates/engine/src/capability/no_op_stateful.rs new file mode 100644 index 0000000000..bccd741917 --- /dev/null +++ b/rust/otap-dataflow/crates/engine/src/capability/no_op_stateful.rs @@ -0,0 +1,76 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//! `NoOpStateful` test capability. +//! +//! A reference capability that carries per-instance mutable state, +//! intended for end-to-end tests that need to verify the extension +//! wiring respects `&mut self` correctly (both natively and through +//! the `SharedAsLocal` adapter), and that per-consumer instance +//! isolation works as advertised. +//! +//! Together with [`NoOpStateless`](super::no_op_stateless::NoOpStateless) +//! these two capabilities cover the matrix of receiver shapes +//! (`&self` / `&mut self`), method modes (sync / async), and argument +//! / return shapes that the `#[capability]` proc macro must support. +//! +//! The `#[capability]` proc macro expands the trait below into: +//! +//! - `pub mod local::NoOpStateful` (`!Send` trait variant) +//! - `pub mod shared::NoOpStateful` (`Send` trait variant) +//! - A `SharedAsLocalNoOpStateful` adapter (auto-delegates `&mut self`) +//! - A zero-sized `pub struct NoOpStateful` registration handle +//! - `local_entry::` / `shared_entry::` factory bridges +//! - A `KNOWN_CAPABILITIES` distributed-slice entry + +use otap_df_engine_macros::capability; + +/// No-op test capability with per-instance mutable state. +/// +/// Reference implementations track simple counters / last-recorded +/// values so tests can assert that mutations on one consumer's +/// instance do not leak into another's (per-consumer `Box`-clone +/// ownership semantics). +#[capability( + name = "no_op_stateful", + description = "No-op test capability with per-instance mutable state" +)] +pub trait NoOpStateful { + /// Number of times [`increment`](Self::increment) has been called + /// on this instance since construction or the last + /// [`reset`](Self::reset). + /// + /// Exercises a `&self` read against state that was last updated + /// by a `&mut self` method. + fn count(&self) -> u64; + + /// Increments the internal counter by one and returns the new + /// value. + /// + /// Exercises the sync `&mut self` codegen path on both the local + /// and shared trait variants, and exercises `SharedAsLocal` + /// `&mut self` delegation through the adapter's + /// `Box` field. + fn increment(&mut self) -> u64; + + /// Clears all mutable state to its initial value (counter back + /// to zero, last-recorded back to `None`). + /// + /// Exercises a sync `&mut self` method with no return value. + fn reset(&mut self); + + /// Records a value asynchronously and returns the previous + /// `count()` plus the recorded value (a synthetic acknowledgment + /// chosen to make the call observable in tests). + /// + /// Exercises the async `&mut self` codegen path with an owned + /// argument and an owned return. + async fn record(&mut self, value: u64) -> u64; + + /// Returns the value of the most recent [`record`](Self::record) + /// call on this instance, or `None` if `record` has never been + /// called (or the instance has been reset since). + /// + /// Exercises a `&self` read returning an owned `Option<_>`. + fn last_recorded(&self) -> Option; +} diff --git a/rust/otap-dataflow/crates/engine/src/capability/no_op_stateless.rs b/rust/otap-dataflow/crates/engine/src/capability/no_op_stateless.rs new file mode 100644 index 0000000000..60eca452a0 --- /dev/null +++ b/rust/otap-dataflow/crates/engine/src/capability/no_op_stateless.rs @@ -0,0 +1,58 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//! `NoOpStateless` test capability. +//! +//! A reference capability with no internal state, intended for end-to-end +//! tests of the extension/capability wiring (registration, resolution, +//! `SharedAsLocal` fallback, builder APIs). Methods cover the relevant +//! codegen permutations: `&self` × {sync, async} × {with-args, no-args} × +//! {borrowed return, owned return}. +//! +//! The `#[capability]` proc macro expands the trait below into: +//! +//! - `pub mod local::NoOpStateless` (`!Send` trait variant) +//! - `pub mod shared::NoOpStateless` (`Send` trait variant) +//! - A `SharedAsLocalNoOpStateless` adapter +//! - A zero-sized `pub struct NoOpStateless` registration handle +//! - `local_entry::` / `shared_entry::` factory bridges +//! - A `KNOWN_CAPABILITIES` distributed-slice entry + +use otap_df_engine_macros::capability; + +/// No-op test capability with no internal state. +/// +/// All methods are pure: they never mutate `self`, and reference +/// implementations can be plain value types with no interior +/// mutability. Used by tests to exercise the registration and +/// resolution paths without entangling them with state semantics. +#[capability( + name = "no_op_stateless", + description = "No-op test capability with no internal state" +)] +pub trait NoOpStateless { + /// Returns a stable identifier for this capability instance. + /// + /// Borrows from `self`, exercising the lifetime-elision codegen + /// path on both the local and shared trait variants. + fn name(&self) -> &str; + + /// Echoes a primitive value synchronously. + /// + /// Exercises the sync-method path with a `Copy` argument and a + /// `Copy` return. + fn echo(&self, value: u64) -> u64; + + /// Returns a fixed token asynchronously. + /// + /// Exercises the async-method path with no arguments. The + /// `#[capability]` macro wires `#[async_trait]` automatically + /// for both the local (`?Send`) and shared trait variants. + async fn ping(&self) -> u64; + + /// Echoes an owned value asynchronously. + /// + /// Exercises the async-method path with an owned (non-`Copy`) + /// argument and an owned return. + async fn echo_async(&self, value: String) -> String; +} diff --git a/rust/otap-dataflow/crates/engine/src/capability/registry/capabilities.rs b/rust/otap-dataflow/crates/engine/src/capability/registry/capabilities.rs index 075f28aefb..f4a86c643d 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/registry/capabilities.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/registry/capabilities.rs @@ -7,8 +7,6 @@ use super::{Error, ResolvedLocalEntry, ResolvedSharedEntry}; use std::any::TypeId; use std::collections::HashMap; -use std::rc::Rc; - /// Per-node capability bindings resolved from the /// [`CapabilityRegistry`](super::CapabilityRegistry). /// @@ -42,17 +40,26 @@ impl Capabilities { /// Resolve a **required** local capability. /// - /// Returns `Rc`. If the capability was registered by a + /// Returns `Box` — a fresh local trait object minted + /// for this consumer. If the capability was registered by a /// shared-only extension, the `SharedAsLocal` adapter is returned /// transparently — the caller always gets a local trait object. /// + /// Each consumer gets its own boxed instance, mirroring + /// [`Self::require_shared`]. Capabilities that need to share state + /// across consumers (across calls or across nodes that bound the + /// same instance) must use `Rc>` (local) or + /// `Arc>` (shared) fields explicitly — there is no + /// implicit fan-out via `Rc::clone`. + /// /// # One-shot contract /// /// Each resolved entry is one-shot per node: a `require_local` /// claim consumes the local entry, and a `require_shared` claim /// consumes the shared entry. Node factories are expected to - /// call each accessor at most once at construction, store the - /// returned handle, and clone/share it within the node as needed. + /// call each accessor at most once at construction and store the + /// returned handle (wrapping it in `Rc<…>` themselves if they need + /// to fan out within the node). /// /// The contract is **per-binding**, not per-execution-model: a /// node claims a binding at most once, regardless of which @@ -89,7 +96,7 @@ impl Capabilities { /// stored entry's concrete type matches `C::Local`). pub fn require_local( &self, - ) -> Result, Error> { + ) -> Result, Error> { let id = TypeId::of::(); // Native local path. `Cell::take()` is the one-shot guard. @@ -100,11 +107,11 @@ impl Capabilities { .ok_or_else(|| Error::CapabilityAlreadyConsumed { capability: C::name().to_owned(), })?; - let rc_any = produce(); - let trait_object = rc_any - .downcast_ref::>() - .cloned() - .unwrap_or_else(|| { + let box_any = produce(); + let trait_object = box_any + .downcast::>() + .map(|b| *b) + .unwrap_or_else(|_| { panic!( "BUG: capability '{}': local entry type mismatch in registry", C::name(), @@ -142,11 +149,11 @@ impl Capabilities { .ok_or_else(|| Error::CapabilityAlreadyConsumed { capability: C::name().to_owned(), })?; - let rc_any = (entry.adapt_as_local)(produce()); - let trait_object = rc_any - .downcast_ref::>() - .cloned() - .unwrap_or_else(|| { + let box_any = (entry.adapt_as_local)(produce()); + let trait_object = box_any + .downcast::>() + .map(|b| *b) + .unwrap_or_else(|_| { panic!( "BUG: capability '{}': SharedAsLocal adapter type mismatch in registry", C::name(), @@ -237,7 +244,7 @@ impl Capabilities { /// Panics on a type-erasure downcast mismatch (registry bug). pub fn optional_local( &self, - ) -> Result>, Error> { + ) -> Result>, Error> { let id = TypeId::of::(); // Available either as a native local entry or as a // SharedAsLocal fallback through the shared entry. diff --git a/rust/otap-dataflow/crates/engine/src/capability/registry/entry.rs b/rust/otap-dataflow/crates/engine/src/capability/registry/entry.rs index 70cc5e1935..6f1735c036 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/registry/entry.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/registry/entry.rs @@ -49,16 +49,17 @@ where /// Object-safe `Fn + Clone` producing an erased local trait object. /// -/// The stored closure returns `Rc>` erased as -/// `Rc` — the double-`Rc` envelope the registry uses. +/// The stored closure returns `Box>` erased as +/// `Box` — the double-box envelope the registry uses, +/// mirroring the shared-side shape (without the `Send` bound). #[doc(hidden)] -pub trait LocalProduce: Fn() -> Rc { +pub trait LocalProduce: Fn() -> Box { fn clone_box(&self) -> Box; } impl LocalProduce for F where - F: Fn() -> Rc + Clone + 'static, + F: Fn() -> Box + Clone + 'static, { fn clone_box(&self) -> Box { Box::new(self.clone()) @@ -70,14 +71,14 @@ where /// A type-erased local (!Send) capability entry. /// /// The `produce` closure — built by the `#[capability]`-generated -/// `local_entry::` caster — mints a fresh `Rc` by +/// `local_entry::` caster — mints a fresh `Box` by /// calling the extension's `LocalInstanceFactory`, downcasting to `E`, /// and coercing to the trait object. #[doc(hidden)] pub struct LocalCapabilityEntry { /// The extension that provided this capability. pub(crate) extension_id: ExtensionId, - /// Produce a fresh local trait object erased as `Rc`. + /// Produce a fresh local trait object erased as `Box`. pub(crate) produce: Box, } @@ -86,7 +87,7 @@ impl LocalCapabilityEntry { #[must_use] pub fn new(extension_id: ExtensionId, produce: F) -> Self where - F: Fn() -> Rc + Clone + 'static, + F: Fn() -> Box + Clone + 'static, { Self { extension_id, @@ -103,7 +104,7 @@ impl LocalCapabilityEntry { /// - `produce` — builds `Box>` erased as /// `Box`. Called per consumer. /// - `adapt_as_local` — takes that same erased double box and returns -/// `Rc>` erased as `Rc`. Used by the +/// `Box>` erased as `Box`. Used by the /// `SharedAsLocal` fallback path in `resolve_bindings`. #[doc(hidden)] pub struct SharedCapabilityEntry { @@ -115,7 +116,7 @@ pub struct SharedCapabilityEntry { /// Turn a produced shared trait object (erased) into a local trait /// object (erased). Only called during resolve for the /// `SharedAsLocal` fallback. - pub(crate) adapt_as_local: fn(Box) -> Rc, + pub(crate) adapt_as_local: fn(Box) -> Box, } impl SharedCapabilityEntry { @@ -125,7 +126,7 @@ impl SharedCapabilityEntry { pub fn new( extension_id: ExtensionId, produce: F, - adapt_as_local: fn(Box) -> Rc, + adapt_as_local: fn(Box) -> Box, ) -> Self where F: Fn() -> Box + Send + Clone + 'static, @@ -172,7 +173,7 @@ pub(crate) struct ResolvedLocalEntry { /// takes its `produce` cell to mint a `Box`. As the /// SharedAsLocal fallback for a binding with no native local entry, /// [`Capabilities::require_local`] takes the same cell and routes the -/// output through `adapt_as_local` to mint an `Rc`. +/// output through `adapt_as_local` to mint a `Box`. /// Either path consumes the single underlying [`Cell::take()`], so the /// per-binding one-shot contract is enforced naturally without an /// auxiliary claim flag. @@ -190,8 +191,8 @@ pub(crate) struct ResolvedSharedEntry { /// Adapter fn pointer used by the SharedAsLocal fallback path /// in [`Capabilities::require_local`]. Takes a produced shared /// trait object (erased as `Box`) and returns a - /// local trait object (erased as `Rc` wrapping - /// `Rc`). Originally minted by the + /// local trait object (erased as `Box` wrapping + /// `Box`). Originally minted by the /// `#[capability]`-generated `shared_entry::` caster. - pub(crate) adapt_as_local: fn(Box) -> Rc, + pub(crate) adapt_as_local: fn(Box) -> Box, } diff --git a/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs b/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs index 1285b31454..5293ba1aab 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs @@ -14,7 +14,6 @@ use super::*; use otap_df_config::ExtensionId; use std::any::{Any, TypeId}; use std::collections::{HashMap, HashSet}; -use std::rc::Rc; // ── Hand-written test capability ───────────────────────────────────── @@ -38,14 +37,14 @@ impl super::super::ExtensionCapability for TestCap { type Local = dyn TestCapLocal; type Shared = dyn TestCapShared; - fn wrap_shared_as_local(shared: Box) -> Rc { + fn wrap_shared_as_local(shared: Box) -> Box { struct Adapter(Box); impl TestCapLocal for Adapter { fn value(&self) -> &str { self.0.value() } } - Rc::new(Adapter(shared)) + Box::new(Adapter(shared)) } } @@ -82,11 +81,11 @@ impl TestCap { let shared: Box = concrete; Box::new(shared) as Box }; - let adapt_as_local: fn(Box) -> Rc = |erased| { + let adapt_as_local: fn(Box) -> Box = |erased| { let shared: Box> = erased.downcast().expect("envelope"); - let rc_local = + let boxed_local = ::wrap_shared_as_local(*shared); - Rc::new(rc_local) as Rc + Box::new(boxed_local) as Box }; SharedCapabilityEntry::new(extension_id, produce, adapt_as_local) } @@ -98,13 +97,13 @@ impl TestCap { where E: TestCapLocal + 'static, { - let produce = move || -> Rc { + let produce = move || -> Box { let erased = factory.produce(); - let concrete: Rc = erased + let concrete: Box = erased .downcast() .expect("instance_factory produced wrong type"); - let local: Rc = concrete; - Rc::new(local) as Rc + let local: Box = concrete; + Box::new(local) as Box }; LocalCapabilityEntry::new(extension_id, produce) } @@ -120,6 +119,7 @@ impl TestCapShared for SharedImpl { } } +#[derive(Clone)] struct LocalImpl(&'static str); impl TestCapLocal for LocalImpl { fn value(&self) -> &str { @@ -138,10 +138,10 @@ fn shared_instance_factory(val: &'static str) -> crate::capability::SharedInstan }) } -// Build a LocalInstanceFactory producing a shared `Rc`. +// Build a LocalInstanceFactory producing a fresh `Box` +// per call. Mimics the builder's clone-per-consumer output. fn local_instance_factory(val: &'static str) -> crate::capability::LocalInstanceFactory { - let shared: Rc = Rc::new(LocalImpl(val)); - crate::capability::LocalInstanceFactory::new(move || Rc::clone(&shared) as Rc) + crate::capability::LocalInstanceFactory::new(move || Box::new(LocalImpl(val)) as Box) } fn register_shared(registry: &mut CapabilityRegistry, ext_id: &'static str, val: &'static str) { @@ -863,7 +863,7 @@ fn test_end_to_end_local_only_via_bundle() { let bundle = ExtensionWrapper::builder(name.clone(), user_config, &runtime_config) .passive() .cloned() - .local(Rc::new(LocalImpl("kv-value"))) + .local(LocalImpl("kv-value")) .build() .expect("bundle builds"); @@ -921,6 +921,7 @@ fn test_end_to_end_shared_constructed_policy_mints_independent_instances() { // ConstructedImpl: each `start()` increments a shared counter so // we can confirm the factory ran per-consumer. #[derive(Clone)] + #[allow(dead_code)] // counter Arc is held to keep refcount-based debugging easy struct ConstructedImpl(Arc, &'static str); impl TestCapShared for ConstructedImpl { fn value(&self) -> &str { @@ -1068,7 +1069,7 @@ fn test_register_into_rejects_metadata_vs_bundle_mismatch() { ) .passive() .cloned() - .local(Rc::new(LocalImpl("v"))) + .local(LocalImpl("v")) .build() .expect("bundle builds"); @@ -1410,3 +1411,227 @@ fn test_resolve_bindings_background_not_a_provider() { "expected Step-4 'does not provide' error; got: {msg}", ); } + +// ── Box-clone refactor regression tests ───────────────────────────────────── + +/// The local-side registry envelope must mirror the shared envelope +/// shape: `Box>` erased as `Box`. +/// `require_local` downcasts the outer `Any` to `Box` +/// then dereferences it. This test pins the shape so anyone refactoring +/// the macro (or the hand-rolled `local_entry::` here) cannot +/// collapse the double-box without the consumer path failing loudly. +#[test] +fn test_local_entry_produce_uses_double_box_envelope() { + let factory = local_instance_factory("envelope-val"); + let entry = TestCap::local_entry::("ext-env".into(), factory); + + let erased: Box = (entry.produce)(); + let boxed_trait_object: Box> = erased + .downcast::>() + .expect("local_entry must emit Box> erased as Box"); + assert_eq!((*boxed_trait_object).value(), "envelope-val"); +} + +// A separate test capability whose method takes `&mut self` — proving +// the proc macro relaxation is end-to-end exercisable through both the +// native local path and the SharedAsLocal fallback. Hand-rolled because +// `#[capability]` cannot expand inside the engine crate. + +trait MutSelfCapLocal { + fn bump(&mut self) -> u32; +} +trait MutSelfCapShared: Send { + fn bump(&mut self) -> u32; +} + +struct MutSelfCap; +impl super::super::private::Sealed for MutSelfCap {} +impl super::super::ExtensionCapability for MutSelfCap { + const NAME: &'static str = "mut_self_cap"; + type Local = dyn MutSelfCapLocal; + type Shared = dyn MutSelfCapShared; + fn wrap_shared_as_local(shared: Box) -> Box { + struct Adapter(Box); + impl MutSelfCapLocal for Adapter { + fn bump(&mut self) -> u32 { + self.0.bump() + } + } + Box::new(Adapter(shared)) + } +} + +#[allow(unsafe_code)] +#[linkme::distributed_slice(super::super::KNOWN_CAPABILITIES)] +#[linkme(crate = linkme)] +static _MUT_SELF_CAP: super::super::KnownCapability = super::super::KnownCapability { + name: "mut_self_cap", + description: "Test capability with &mut self method", + type_id: || TypeId::of::(), +}; + +impl MutSelfCap { + fn shared_entry( + extension_id: ExtensionId, + factory: crate::capability::SharedInstanceFactory, + ) -> SharedCapabilityEntry + where + E: MutSelfCapShared + 'static, + { + let produce = move || -> Box { + let erased = factory.produce(); + let concrete: Box = erased.downcast().expect("instance factory"); + let shared: Box = concrete; + Box::new(shared) as Box + }; + let adapt_as_local: fn(Box) -> Box = |erased| { + let shared: Box> = erased.downcast().expect("envelope"); + let boxed_local = + ::wrap_shared_as_local(*shared); + Box::new(boxed_local) as Box + }; + SharedCapabilityEntry::new(extension_id, produce, adapt_as_local) + } + + fn local_entry( + extension_id: ExtensionId, + factory: crate::capability::LocalInstanceFactory, + ) -> LocalCapabilityEntry + where + E: MutSelfCapLocal + 'static, + { + let produce = move || -> Box { + let erased = factory.produce(); + let concrete: Box = erased.downcast().expect("instance factory"); + let local: Box = concrete; + Box::new(local) as Box + }; + LocalCapabilityEntry::new(extension_id, produce) + } +} + +#[derive(Clone)] +struct MutSelfImpl { + counter: u32, +} +impl MutSelfCapLocal for MutSelfImpl { + fn bump(&mut self) -> u32 { + self.counter += 1; + self.counter + } +} +impl MutSelfCapShared for MutSelfImpl { + fn bump(&mut self) -> u32 { + self.counter += 1; + self.counter + } +} + +/// `&mut self` methods are callable through `Box` +/// — the whole point of the Box-clone refactor on the local side. +#[test] +fn test_local_capability_supports_mut_self_native() { + let mut reg = CapabilityRegistry::new(); + let factory = crate::capability::LocalInstanceFactory::new(|| { + Box::new(MutSelfImpl { counter: 0 }) as Box + }); + reg.register_local( + TypeId::of::(), + MutSelfCap::local_entry::("ext".into(), factory), + ) + .unwrap(); + + let mut tracker = ConsumedTracker::new(); + let caps = resolve_bindings( + &bindings("mut_self_cap", "ext"), + ®, + &known_exts(&["ext"]), + &mut tracker, + ) + .unwrap(); + + let mut handle = caps.require_local::().unwrap(); + assert_eq!(handle.bump(), 1); + assert_eq!(handle.bump(), 2); + assert_eq!(handle.bump(), 3); +} + +/// `&mut self` is also callable through the SharedAsLocal fallback — +/// the adapter holds the inner `Box` in a field +/// (`self.0`), which `&mut self` adapter methods can reborrow as +/// `&mut *self.0` to delegate. +#[test] +fn test_local_capability_supports_mut_self_via_shared_as_local() { + let mut reg = CapabilityRegistry::new(); + let factory = crate::capability::SharedInstanceFactory::new(|| { + Box::new(MutSelfImpl { counter: 0 }) as Box + }); + reg.register_shared( + TypeId::of::(), + MutSelfCap::shared_entry::("ext".into(), factory), + ) + .unwrap(); + + let mut tracker = ConsumedTracker::new(); + let caps = resolve_bindings( + &bindings("mut_self_cap", "ext"), + ®, + &known_exts(&["ext"]), + &mut tracker, + ) + .unwrap(); + + // Local consumer claim goes through the SharedAsLocal fallback. + let mut handle = caps.require_local::().unwrap(); + assert_eq!(handle.bump(), 1); + assert_eq!(handle.bump(), 2); +} + +/// Two consumers binding the same `.passive().cloned().local(E)` +/// extension must each receive an independent boxed clone. Mutating +/// one must not be observable through the other — proving the Box- +/// clone refactor honors per-consumer ownership rather than fanning +/// `Rc::clone`s of a shared instance. +#[test] +fn test_passive_cloned_local_hands_out_independent_clones() { + let mut reg = CapabilityRegistry::new(); + // Use a per-consumer-cloning factory: each `produce()` call returns + // a fresh `Box` derived from the prototype, mirroring + // what `set_local_cloned` builds in the builder. + let prototype = MutSelfImpl { counter: 0 }; + let factory = crate::capability::LocalInstanceFactory::new(move || { + Box::new(prototype.clone()) as Box + }); + reg.register_local( + TypeId::of::(), + MutSelfCap::local_entry::("ext".into(), factory), + ) + .unwrap(); + + // Two independent resolutions = two independent consumer nodes. + let mut tracker = ConsumedTracker::new(); + let caps_a = resolve_bindings( + &bindings("mut_self_cap", "ext"), + ®, + &known_exts(&["ext"]), + &mut tracker, + ) + .unwrap(); + let caps_b = resolve_bindings( + &bindings("mut_self_cap", "ext"), + ®, + &known_exts(&["ext"]), + &mut tracker, + ) + .unwrap(); + + let mut handle_a = caps_a.require_local::().unwrap(); + let mut handle_b = caps_b.require_local::().unwrap(); + + // Mutating A should not affect B. + assert_eq!(handle_a.bump(), 1); + assert_eq!(handle_a.bump(), 2); + assert_eq!(handle_b.bump(), 1, "consumer B must start from prototype"); + assert_eq!(handle_a.bump(), 3, "consumer A must keep its own counter"); + assert_eq!(handle_b.bump(), 2, "consumer B must keep its own counter"); +} diff --git a/rust/otap-dataflow/crates/engine/src/capability/tests.rs b/rust/otap-dataflow/crates/engine/src/capability/tests.rs index 519fbefdb6..ac3759a746 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/tests.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/tests.rs @@ -19,7 +19,6 @@ use crate::capability::factory::{LocalInstanceFactory, SharedInstanceFactory}; use otap_df_config::{CapabilityId, ExtensionId}; use std::any::{Any, TypeId}; use std::collections::{HashMap, HashSet}; -use std::rc::Rc; // ── Test capability (mirrors what `#[capability]` generates) ───────── @@ -36,14 +35,14 @@ impl ExtensionCapability for MacroTestCap { const NAME: &'static str = "macro_test_cap"; type Local = dyn MacroTestCapLocal; type Shared = dyn MacroTestCapShared; - fn wrap_shared_as_local(shared: Box) -> Rc { + fn wrap_shared_as_local(shared: Box) -> Box { struct Adapter(Box); impl MacroTestCapLocal for Adapter { fn value(&self) -> &str { self.0.value() } } - Rc::new(Adapter(shared)) + Box::new(Adapter(shared)) } } @@ -70,10 +69,10 @@ impl MacroTestCap { let shared: Box = concrete; Box::new(shared) as Box }; - let adapt_as_local: fn(Box) -> Rc = |erased| { + let adapt_as_local: fn(Box) -> Box = |erased| { let shared: Box> = erased.downcast().expect("envelope"); - let rc_local = ::wrap_shared_as_local(*shared); - Rc::new(rc_local) as Rc + let boxed_local = ::wrap_shared_as_local(*shared); + Box::new(boxed_local) as Box }; SharedCapabilityEntry::new(ext_id, produce, adapt_as_local) } @@ -82,11 +81,11 @@ impl MacroTestCap { where E: MacroTestCapLocal + 'static, { - let produce = move || -> Rc { + let produce = move || -> Box { let erased = factory.produce(); - let concrete: Rc = erased.downcast().expect("instance factory"); - let local: Rc = concrete; - Rc::new(local) as Rc + let concrete: Box = erased.downcast().expect("instance factory"); + let local: Box = concrete; + Box::new(local) as Box }; LocalCapabilityEntry::new(ext_id, produce) } @@ -102,6 +101,7 @@ impl MacroTestCapShared for Shared { } } +#[derive(Clone)] struct Local(&'static str); impl MacroTestCapLocal for Local { fn value(&self) -> &str { @@ -113,8 +113,7 @@ fn shared_factory(val: &'static str) -> SharedInstanceFactory { SharedInstanceFactory::new(move || Box::new(Shared(val)) as Box) } fn local_factory(val: &'static str) -> LocalInstanceFactory { - let shared: Rc = Rc::new(Local(val)); - LocalInstanceFactory::new(move || Rc::clone(&shared) as Rc) + LocalInstanceFactory::new(move || Box::new(Local(val)) as Box) } fn bindings() -> HashMap { diff --git a/rust/otap-dataflow/crates/engine/src/error.rs b/rust/otap-dataflow/crates/engine/src/error.rs index 1a406f4116..b6221c9e0e 100644 --- a/rust/otap-dataflow/crates/engine/src/error.rs +++ b/rust/otap-dataflow/crates/engine/src/error.rs @@ -346,6 +346,31 @@ pub enum Error { extension: ExtensionId, }, + /// Unknown extension plugin. + #[error("Unknown extension plugin `{plugin_urn}`")] + UnknownExtension { + /// The URN of the unknown extension plugin. + plugin_urn: String, + }, + + /// Capability registration failed for an extension. + #[error("Failed to register capabilities for extension `{extension}`: {message}")] + CapabilityRegistrationFailed { + /// The extension whose capability registration failed. + extension: ExtensionId, + /// Underlying error message. + message: String, + }, + + /// Capability resolution failed for a node. + #[error("Failed to resolve capability bindings for node `{node}`: {message}")] + CapabilityResolutionFailed { + /// The node whose capability resolution failed. + node: NodeName, + /// Underlying error message. + message: String, + }, + /// Unknown node. #[error("Unknown node `{node}`")] UnknownNode { @@ -560,6 +585,9 @@ impl Error { Error::UnknownExporter { .. } => "UnknownExporter", Error::ExtensionInNodesSection { .. } => "ExtensionInNodesSection", Error::ExtensionAlreadyExists { .. } => "ExtensionAlreadyExists", + Error::UnknownExtension { .. } => "UnknownExtension", + Error::CapabilityRegistrationFailed { .. } => "CapabilityRegistrationFailed", + Error::CapabilityResolutionFailed { .. } => "CapabilityResolutionFailed", Error::UnknownNode { .. } => "UnknownNode", Error::UnknownOutputPort { .. } => "UnknownOutputPort", Error::UnknownProcessor { .. } => "UnknownProcessor", diff --git a/rust/otap-dataflow/crates/engine/src/extension/builder.rs b/rust/otap-dataflow/crates/engine/src/extension/builder.rs index 565dbd3acd..d55f8179d4 100644 --- a/rust/otap-dataflow/crates/engine/src/extension/builder.rs +++ b/rust/otap-dataflow/crates/engine/src/extension/builder.rs @@ -148,10 +148,15 @@ impl ActiveStage { } /// Register the local (!Send) variant. + /// + /// `E: Clone` is required so capability consumers can each receive + /// an independent boxed clone of `*extension`. The original `Rc` + /// is what the engine will hand to `local_ext::Extension::start`, + /// so the lifecycle wiring still uses an `Rc`-hosted instance. #[must_use] pub fn local(mut self, extension: std::rc::Rc) -> ActiveLocalStage where - E: local_ext::Extension + 'static, + E: local_ext::Extension + Clone + 'static, { self.parent.set_local_active(extension); ActiveLocalStage { @@ -169,10 +174,13 @@ pub struct ActiveSharedStage { impl ActiveSharedStage { /// Register the local (!Send) variant alongside the previously-registered /// shared variant. + /// + /// `E: Clone` is required for the same reason as + /// [`ActiveStage::local`]. #[must_use] pub fn local(mut self, extension: std::rc::Rc) -> ActiveCompleteStage where - E: local_ext::Extension + 'static, + E: local_ext::Extension + Clone + 'static, { self.parent.set_local_active(extension); ActiveCompleteStage { @@ -293,11 +301,11 @@ impl PassiveClonedStage { } /// Register the local (!Send) variant. Consumers receive - /// `Rc::clone`s of the stored instance. + /// independent clones of the prototype, mirroring `.shared(...)`. #[must_use] - pub fn local(mut self, extension: std::rc::Rc) -> PassiveClonedLocalStage + pub fn local(mut self, extension: E) -> PassiveClonedLocalStage where - E: 'static, + E: Clone + 'static, { self.parent.set_local_cloned(extension); PassiveClonedLocalStage { @@ -317,9 +325,9 @@ impl PassiveClonedSharedStage { /// Register the local (!Send) variant alongside the previously-registered /// shared variant. #[must_use] - pub fn local(mut self, extension: std::rc::Rc) -> PassiveClonedCompleteStage + pub fn local(mut self, extension: E) -> PassiveClonedCompleteStage where - E: 'static, + E: Clone + 'static, { self.parent.set_local_cloned(extension); PassiveClonedCompleteStage { @@ -419,7 +427,7 @@ impl PassiveConstructedStage { pub fn local(mut self, produce: F) -> PassiveConstructedLocalStage where E: 'static, - F: Fn() -> std::rc::Rc + Clone + 'static, + F: Fn() -> E + Clone + 'static, { self.parent.set_local_constructed::(produce); PassiveConstructedLocalStage { @@ -442,7 +450,7 @@ impl PassiveConstructedSharedStage { pub fn local(mut self, produce: F) -> PassiveConstructedCompleteStage where E: 'static, - F: Fn() -> std::rc::Rc + Clone + 'static, + F: Fn() -> E + Clone + 'static, { self.parent.set_local_constructed::(produce); PassiveConstructedCompleteStage { @@ -553,7 +561,7 @@ impl BackgroundEmptyStage { #[must_use] pub fn local(mut self, extension: std::rc::Rc) -> BackgroundCompleteStage where - E: local_ext::Extension + 'static, + E: local_ext::Extension + Clone + 'static, { self.parent.set_local_active(extension); BackgroundCompleteStage { @@ -631,13 +639,13 @@ impl ExtensionBundleBuilder { fn set_local_active(&mut self, extension: std::rc::Rc) where - E: local_ext::Extension + 'static, + E: local_ext::Extension + Clone + 'static, { let for_factory = std::rc::Rc::clone(&extension); self.local = Some(LocalDecomposed { extension: Some(extension), instance_factory: LocalInstanceFactory::new(move || { - std::rc::Rc::clone(&for_factory) as std::rc::Rc + Box::new((*for_factory).clone()) as Box }), type_id: TypeId::of::(), }); @@ -656,14 +664,14 @@ impl ExtensionBundleBuilder { }); } - fn set_local_cloned(&mut self, extension: std::rc::Rc) + fn set_local_cloned(&mut self, extension: E) where - E: 'static, + E: Clone + 'static, { self.local = Some(LocalDecomposed { extension: None, instance_factory: LocalInstanceFactory::new(move || { - std::rc::Rc::clone(&extension) as std::rc::Rc + Box::new(extension.clone()) as Box }), type_id: TypeId::of::(), }); @@ -686,11 +694,13 @@ impl ExtensionBundleBuilder { fn set_local_constructed(&mut self, produce: F) where E: 'static, - F: Fn() -> std::rc::Rc + Clone + 'static, + F: Fn() -> E + Clone + 'static, { self.local = Some(LocalDecomposed { extension: None, - instance_factory: LocalInstanceFactory::new(move || produce() as std::rc::Rc), + instance_factory: LocalInstanceFactory::new(move || { + Box::new(produce()) as Box + }), type_id: TypeId::of::(), }); } diff --git a/rust/otap-dataflow/crates/engine/src/extension/tests.rs b/rust/otap-dataflow/crates/engine/src/extension/tests.rs index 54ef37146b..8b0dd6c75e 100644 --- a/rust/otap-dataflow/crates/engine/src/extension/tests.rs +++ b/rust/otap-dataflow/crates/engine/src/extension/tests.rs @@ -145,7 +145,7 @@ fn test_local_passive() { let mut set = ExtensionWrapper::builder(n, u, &c) .passive() .cloned() - .local(std::rc::Rc::new(42u32)) + .local(42u32) .build() .unwrap(); assert!(set.shared().is_none()); @@ -508,7 +508,7 @@ fn test_dual_passive() { let mut set = ExtensionWrapper::builder(n, u, &c) .passive() .cloned() - .local(std::rc::Rc::new(42u32)) + .local(42u32) .shared("data".to_string()) .build() .unwrap(); @@ -522,7 +522,7 @@ fn test_dual_passive_fresh() { let mut set = ExtensionWrapper::builder(n, u, &c) .passive() .constructed() - .local(|| std::rc::Rc::new(42u32)) + .local(|| 42u32) .shared(|| "data".to_string()) .build() .unwrap(); diff --git a/rust/otap-dataflow/crates/engine/src/lib.rs b/rust/otap-dataflow/crates/engine/src/lib.rs index 1f51ce1a12..f18541894b 100644 --- a/rust/otap-dataflow/crates/engine/src/lib.rs +++ b/rust/otap-dataflow/crates/engine/src/lib.rs @@ -51,9 +51,6 @@ use std::{ sync::OnceLock, }; -// TODO: remove `dead_code` once the capability system is wired into the -// pipeline build. -#[allow(dead_code)] pub mod capability; #[doc(hidden)] pub mod clock; @@ -109,11 +106,16 @@ pub struct ReceiverFactory { /// The name of the receiver. pub name: &'static str, /// A function that creates a new receiver instance. + /// + /// `capabilities` is a per-node, one-shot view of the extension capabilities + /// bound to this receiver in the pipeline configuration. Factories that + /// don't depend on any extension can ignore the parameter. pub create: fn( pipeline_ctx: PipelineContext, node: NodeId, node_config: Arc, receiver_config: &ReceiverConfig, + capabilities: &capability::registry::Capabilities, ) -> Result, otap_df_config::error::Error>, /// Optional wiring constraints enforced during pipeline build. pub wiring_contract: wiring_contract::WiringContract, @@ -148,11 +150,16 @@ pub struct ProcessorFactory { /// The name of the processor. pub name: &'static str, /// A function that creates a new processor instance. + /// + /// `capabilities` is a per-node, one-shot view of the extension capabilities + /// bound to this processor in the pipeline configuration. Factories that + /// don't depend on any extension can ignore the parameter. pub create: fn( pipeline: PipelineContext, node: NodeId, node_config: Arc, processor_config: &ProcessorConfig, + capabilities: &capability::registry::Capabilities, ) -> Result, otap_df_config::error::Error>, /// Optional wiring constraints enforced during pipeline build. pub wiring_contract: wiring_contract::WiringContract, @@ -187,11 +194,16 @@ pub struct ExporterFactory { /// The name of the receiver. pub name: &'static str, /// A function that creates a new exporter instance. + /// + /// `capabilities` is a per-node, one-shot view of the extension capabilities + /// bound to this exporter in the pipeline configuration. Factories that + /// don't depend on any extension can ignore the parameter. pub create: fn( pipeline: PipelineContext, node: NodeId, node_config: Arc, exporter_config: &ExporterConfig, + capabilities: &capability::registry::Capabilities, ) -> Result, otap_df_config::error::Error>, /// Optional wiring constraints enforced during pipeline build. pub wiring_contract: wiring_contract::WiringContract, @@ -759,9 +771,82 @@ impl PipelineFactory { ); pipeline_ctx.set_node_names(node_names); + // ── Extension instantiation + capability registry build ───────────── + // + // Run before node-wrapper creation so resolve_bindings can validate + // each node's `node_config.capabilities` against the populated + // registry, and so factories that call `require_local::()` / + // `require_shared::()` see a fully-populated `Capabilities`. + // Capabilities are resolved EAGERLY at build time — node create() + // bodies run inside this same `build` call, so extension `start()` + // side effects (which happen later, in `run_forever`) cannot be + // observed by capability construction. + let known_extensions: HashSet = + config.extensions().keys().cloned().collect(); + let mut capability_registry = capability::registry::CapabilityRegistry::new(); + // Each entry tracks (extension id, bundle, is_background). The + // `is_background` flag is captured here while we still have the + // factory in hand — Background extensions register zero + // capabilities (`factory.capabilities == None`), and the + // post-build pruning step uses this flag to keep them + // unconditionally (they are engine-driven and do not need a + // node binding to be useful). + let mut extension_bundles: Vec<(otap_df_config::ExtensionId, ExtensionBundle, bool)> = + Vec::new(); + for (ext_id, ext_user_config) in config.extension_iter() { + let raw_urn = ext_user_config.r#type.as_str(); + let factory = self + .get_extension_factory_map() + .get(raw_urn) + .ok_or_else(|| Error::UnknownExtension { + plugin_urn: raw_urn.to_string(), + })?; + let runtime_config = ExtensionConfig::with_control_channel_capacity( + ext_id.clone(), + channel_capacity_policy.control.node, + ); + let bundle = (factory.create)( + pipeline_ctx.clone(), + ext_id.clone(), + ext_user_config.clone(), + &runtime_config, + ) + .map_err(|e| Error::ConfigError(Box::new(e)))?; + bundle + .register_into(factory.capabilities.as_ref(), &mut capability_registry) + .map_err(|e| Error::CapabilityRegistrationFailed { + extension: ext_id.clone(), + message: format!("{e}"), + })?; + let is_background = factory.capabilities.is_none(); + extension_bundles.push((ext_id.clone(), bundle, is_background)); + } + + // Resolve each node's bindings against the populated registry. A + // single shared `ConsumedTracker` records consumption across all + // nodes so the engine can prune unused extension variants after + // the build phase. + let mut consumed_tracker = capability::registry::ConsumedTracker::new(); + let mut per_node_capabilities: HashMap = + HashMap::new(); + for (name, node_config) in config.node_iter() { + let caps = capability::registry::resolve_bindings( + &node_config.capabilities, + &capability_registry, + &known_extensions, + &mut consumed_tracker, + ) + .map_err(|e| Error::CapabilityResolutionFailed { + node: name.clone(), + message: format!("{e}"), + })?; + let _ = per_node_capabilities.insert(name.clone(), caps); + } + // Second pass: create runtime nodes. Node IDs were pre-assigned above, // so we look them up from `node_ids` instead of calling `next_node_id`. // ToDo(LQ): Collect all errors instead of failing fast to provide better feedback. + let empty_capabilities = capability::registry::Capabilities::empty(); for (name, node_config) in config.node_iter() { let node_kind = node_config.kind(); let node_id = node_ids.get(name).expect("allocated in first pass").clone(); @@ -771,6 +856,13 @@ impl PipelineFactory { node_kind, node_config.identity_attributes(), ); + // Per-node Capabilities resolved in the build-time pass above. + // Falls back to empty for nodes that declared no bindings (the + // resolver populates the map for every node, including those + // with no `capabilities` block, so this fallback is defensive). + let node_capabilities = per_node_capabilities + .get(name) + .unwrap_or(&empty_capabilities); match node_kind { otap_df_config::node::NodeKind::Receiver => { @@ -797,6 +889,7 @@ impl PipelineFactory { channel_capacity_policy.control.node, channel_capacity_policy.pdata, &transport_headers_policy, + node_capabilities, ) }, )?; @@ -816,6 +909,7 @@ impl PipelineFactory { node_config.clone(), channel_capacity_policy.control.node, channel_capacity_policy.pdata, + node_capabilities, ) }, )?; @@ -836,6 +930,7 @@ impl PipelineFactory { channel_capacity_policy.control.node, channel_capacity_policy.pdata, &transport_headers_policy, + node_capabilities, ) }, )?; @@ -851,6 +946,131 @@ impl PipelineFactory { } } + // ── Decide which extension variants to keep ──────────────────── + // + // Three categories of extension-level decision are handled here. + // Per-variant decisions (drop a single local or shared variant + // because nothing consumes it while the other variant *is* + // consumed) are made silently — no warning, since the extension + // as a whole is serving its purpose. + // + // 1. **Background extension** (`factory.capabilities == None`): + // always kept. Background extensions are engine-driven and + // register zero capabilities, so they cannot appear in any + // node's binding map and cannot show up in the consumed + // tracker. Pruning them based on consumption would silently + // drop their event loop, which is exactly the work they + // exist to do. They're spawned in `run_forever` like Active. + // + // 2. **Defined but unbound** (no node references this extension + // from `node_config.capabilities`): warn + drop the entire + // bundle. The author wrote an extension into the pipeline + // config but no node references it — keeping it would + // waste the resources of an active lifecycle (or hold + // passive state) for nothing. The warning helps debug + // "why isn't my extension running?" by surfacing the + // missing binding. + // + // 3. **Bound but neither variant consumed**: warn + drop the + // entire bundle. At least one node declared a binding to + // this extension but no node's `create()` actually called + // `require_*` / `optional_*` for *any* of its variants. + // The warning surfaces node factories that declared a + // binding but forgot to consume it. + // + // 3a. **Bound and at least one variant consumed**: keep each + // consumed variant; silently drop the variant(s) that + // weren't consumed. Dropping an unused variant when the + // other is in use is a normal optimization (no node ever + // wanted that path), not an error condition. + // + // A bundle's two variants (local + shared) are evaluated + // independently in 3/3a — a SharedAsLocal-fallback bundle + // (shared-only) only ever populates `unconsumed_shared`, so the + // local check is automatically a no-op for it. + let bound_extensions: HashSet = config + .node_iter() + .flat_map(|(_, node_config)| node_config.capabilities.values().cloned()) + .collect(); + let unconsumed_local: HashSet = consumed_tracker + .unconsumed_local() + .into_iter() + .map(|(ext_id, _name)| ext_id) + .collect(); + let unconsumed_shared: HashSet = consumed_tracker + .unconsumed_shared() + .into_iter() + .map(|(ext_id, _name)| ext_id) + .collect(); + let extension_wrappers: Vec = extension_bundles + .into_iter() + .flat_map(|(ext_id, mut bundle, is_background)| { + let mut kept: Vec = Vec::new(); + + // Category 1: Background — always kept, no warning. + if is_background { + if let Some(local) = bundle.take_local() { + kept.push(local); + } + if let Some(shared) = bundle.take_shared() { + kept.push(shared); + } + return kept; + } + + // Category 2: defined but no node binds to it. Warn and + // drop the whole bundle (both variants if present). + if !bound_extensions.contains(&ext_id) { + otel_warn!( + "extension.unbound", + message = "extension defined in pipeline config but no node binds to any of its capabilities; dropping", + pipeline_group_id = pipeline_group_id.as_ref(), + pipeline_id = pipeline_id.as_ref(), + core_id = core_id, + extension = ext_id.as_ref(), + ); + return kept; + } + + // Category 3 / 3a: per-variant consumption. + // A variant is "consumed" iff it exists in the bundle + // AND its tracker slot was flipped to `true` (i.e., the + // ext_id is *absent* from the unconsumed set). + let local_present = bundle.local().is_some(); + let shared_present = bundle.shared().is_some(); + let local_consumed = local_present && !unconsumed_local.contains(&ext_id); + let shared_consumed = shared_present && !unconsumed_shared.contains(&ext_id); + + // Category 3: bound but no variant consumed → warn + drop. + if !local_consumed && !shared_consumed { + otel_warn!( + "extension.unconsumed", + message = "node bindings reference this extension but no node called require_*/optional_* for any of its variants; dropping", + pipeline_group_id = pipeline_group_id.as_ref(), + pipeline_id = pipeline_id.as_ref(), + core_id = core_id, + extension = ext_id.as_ref(), + ); + return kept; + } + + // Category 3a: at least one variant consumed. Keep the + // consumed variant(s); silently drop the unused one — no + // warning, since the extension as a whole is in use. + if let Some(local) = bundle.take_local() + && local_consumed + { + kept.push(local); + } + if let Some(shared) = bundle.take_shared() + && shared_consumed + { + kept.push(shared); + } + kept + }) + .collect(); + let edges = collect_hyper_edges_runtime_from_connections(&config, &build_state)?; // First pass: plan hyper-edge wiring to avoid multiple mutable borrows @@ -862,6 +1082,7 @@ impl PipelineFactory { receivers, processors, exporters, + extension_wrappers, nodes, telemetry_policy, ); @@ -1482,6 +1703,7 @@ impl PipelineFactory { control_channel_capacity: usize, pdata_channel_capacity: usize, transport_headers_policy: &Option, + capabilities: &capability::registry::Capabilities, ) -> Result, Error> { let pipeline_group_id = pipeline_ctx.pipeline_group_id(); let pipeline_id = pipeline_ctx.pipeline_id(); @@ -1523,6 +1745,7 @@ impl PipelineFactory { node_id.clone(), node_config, &runtime_config, + capabilities, ) .map_err(|e| Error::ConfigError(Box::new(e)))? .with_capture_policy(capture_policy); @@ -1546,6 +1769,7 @@ impl PipelineFactory { node_config: Arc, control_channel_capacity: usize, pdata_channel_capacity: usize, + capabilities: &capability::registry::Capabilities, ) -> Result, Error> { let pipeline_group_id = pipeline_ctx.pipeline_group_id(); let pipeline_id = pipeline_ctx.pipeline_id(); @@ -1585,6 +1809,7 @@ impl PipelineFactory { node_id.clone(), node_config.clone(), &processor_config, + capabilities, ) .map_err(|e| Error::ConfigError(Box::new(e)))?; @@ -1610,6 +1835,7 @@ impl PipelineFactory { control_channel_capacity: usize, pdata_channel_capacity: usize, transport_headers_policy: &Option, + capabilities: &capability::registry::Capabilities, ) -> Result, Error> { let pipeline_group_id = pipeline_ctx.pipeline_group_id(); let pipeline_id = pipeline_ctx.pipeline_id(); @@ -1651,6 +1877,7 @@ impl PipelineFactory { node_id.clone(), node_config, &exporter_config, + capabilities, ) .map_err(|e| Error::ConfigError(Box::new(e)))? .with_propagation_policy(propagation_policy); diff --git a/rust/otap-dataflow/crates/engine/src/local/capability.rs b/rust/otap-dataflow/crates/engine/src/local/capability.rs index 5151f77ded..5c06025908 100644 --- a/rust/otap-dataflow/crates/engine/src/local/capability.rs +++ b/rust/otap-dataflow/crates/engine/src/local/capability.rs @@ -7,6 +7,5 @@ //! Capability traits are defined by the `#[capability]` proc macro in //! per-capability modules under [`capability`](crate::capability). -// TODO: Add re-exports as capabilities are defined, e.g.: -// pub use crate::capability::bearer_token_provider::local::BearerTokenProvider; -// pub use crate::capability::key_value_store::local::KeyValueStore; +pub use crate::capability::no_op_stateful::local::NoOpStateful; +pub use crate::capability::no_op_stateless::local::NoOpStateless; diff --git a/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs b/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs index 515f0590fd..c0c4101ea9 100644 --- a/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs +++ b/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs @@ -104,6 +104,13 @@ pub struct RuntimePipeline { processors: Vec>, /// A map node id to exporter runtime node. exporters: Vec>, + /// Extension wrappers that survived the build-time consumed-tracker pruning. + /// One entry per surviving local-or-shared variant. Active extensions in + /// this list have their lifecycle tasks spawned by `run_forever` before + /// data-path nodes; passive extensions hold their instance factories so + /// `Capabilities::require_*` calls keep working at run time but are not + /// spawned themselves. + extensions: Vec, /// A precomputed map of all node IDs to their Node trait objects (? @@@) for efficient access /// Indexed by NodeIndex @@ -141,6 +148,7 @@ impl RuntimePipeline { receivers: Vec>, processors: Vec>, exporters: Vec>, + extensions: Vec, nodes: NodeDefs, telemetry_policy: TelemetryPolicy, ) -> Self { @@ -149,6 +157,7 @@ impl RuntimePipeline { receivers, processors, exporters, + extensions, nodes, channel_metrics: Default::default(), telemetry_policy, @@ -195,6 +204,7 @@ impl RuntimePipeli receivers, processors, exporters, + extensions, nodes: _nodes, channel_metrics, telemetry_policy, @@ -211,6 +221,72 @@ impl RuntimePipeli let local_tasks = LocalSet::new(); // ToDo create an optimized version of FuturesUnordered that can be used for !Send, !Sync tasks let mut futures = FuturesUnordered::new(); + // Active extensions live in their own collection so we can signal + // them to shut down only after the data-path has fully drained + // (per arch invariant: "extensions start first, shut down last"). + let mut extension_futures: FuturesUnordered>> = + FuturesUnordered::new(); + let mut extension_shutdown_senders: Vec = + Vec::new(); + // Passive extensions hold the engine-side state that capability + // consumers' instances may reference (via cloned `Arc`s minted by + // the builder). Keeping the wrapper alive for the duration of the + // pipeline run prevents that state from being dropped while + // consumers still hold handles. When `run_forever` returns, these + // wrappers drop and any remaining shared state is released. + let mut _passive_extensions: Vec = Vec::new(); + + // Lifecycle invariant: "extensions start first, shut down last". + // Concretely, `start()` is invoked on every active extension before + // any data-path node task is spawned, and `Shutdown` is delivered + // to extensions only after the data path has fully drained. + // + // NOTE: this orders *lifecycle calls*, not init completion. + // `start()` is async, so invoking it merely enqueues a future onto + // the LocalSet; the extension's init body runs concurrently with + // the data path once polling begins. Capability *construction* + // happens at build time (before any spawn), so structural wiring + // is always in place — but if an extension performs deferred async + // init in `start()` (opening a connection, loading config, warming + // a cache), capability consumers may observe the pre-init state + // until that work completes. + // + // Today, extensions handle this themselves (e.g., produce final + // state at capability construction time, or have the capability + // surface a not-ready error/default until init progresses). + // + // TODO: Revisit when an extension actually needs an init-complete + // guarantee. Likely shape: opt-in readiness probe registered at + // build time (e.g. `builder.with_readiness_probe()` returns a + // handle the extension fires from `start()`); the engine awaits + // only the registered probes via `try_join_all` before spawning + // data-path tasks. Extensions that don't opt in keep today's + // behavior with zero overhead. + for ext_wrapper in extensions { + if ext_wrapper.is_passive() { + _passive_extensions.push(ext_wrapper); + continue; + } + if let Some(sender) = ext_wrapper.extension_control_sender() { + extension_shutdown_senders.push(sender); + } + let ext_metrics_reporter = metrics_reporter.clone(); + let ext_id = ext_wrapper.name(); + let fut = async move { + match ext_wrapper.start(ext_metrics_reporter).await { + Ok(_terminal_state) => Ok(()), + Err(e) => { + otap_df_telemetry::otel_warn!( + "extension.task.error", + extension = ext_id.as_ref(), + error = format!("{e}"), + ); + Err(e) + } + } + }; + extension_futures.push(local_tasks.spawn_local(fut)); + } let mut control_senders = ControlSenders::default(); let mut node_metric_entries: Vec<(usize, NodeMetricHandles)> = Vec::new(); @@ -436,29 +512,62 @@ impl RuntimePipeli })); // Drive all local tasks until completion, returning the first error if any. + // Data-path tasks (`futures`) and extension tasks (`extension_futures`) run + // concurrently. When the data-path drains, broadcast `Shutdown` to active + // extensions so they can terminate gracefully, then continue draining + // extension futures. Errors from either side short-circuit and abort. rt.block_on(async { local_tasks .run_until(async { let mut task_results = Vec::new(); + let mut shutdown_signaled = false; - // Process each future as they complete and handle errors - while let Some(result) = futures.next().await { - match result { - Ok(Ok(res)) => { - // Task completed successfully, collect its result - task_results.push(res); + loop { + tokio::select! { + biased; + Some(result) = futures.next(), if !futures.is_empty() => { + match result { + Ok(Ok(res)) => task_results.push(res), + Ok(Err(e)) => return Err(e), + Err(e) => return Err(Error::JoinTaskError { + is_canceled: e.is_cancelled(), + is_panic: e.is_panic(), + error: e.to_string(), + }), + } } - Ok(Err(e)) => { - // A task returned an error - return Err(e); + Some(result) = extension_futures.next(), if !extension_futures.is_empty() => { + match result { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(e), + Err(e) => return Err(Error::JoinTaskError { + is_canceled: e.is_cancelled(), + is_panic: e.is_panic(), + error: e.to_string(), + }), + } } - Err(e) => { - // JoinError (panic or cancellation) - return Err(Error::JoinTaskError { - is_canceled: e.is_cancelled(), - is_panic: e.is_panic(), - error: e.to_string(), - }); + else => break, + } + + // Once data-path is drained, fire the shutdown + // broadcast exactly once. Active extensions react + // by exiting their event loops and producing a + // terminal state, draining `extension_futures`. + if !shutdown_signaled + && futures.is_empty() + && !extension_shutdown_senders.is_empty() + { + shutdown_signaled = true; + let deadline = tokio::time::Instant::now() + + Duration::from_secs(5); + for sender in &extension_shutdown_senders { + let _ = sender.sender.try_send( + crate::control::ExtensionControlMsg::Shutdown { + deadline: deadline.into_std(), + reason: "pipeline data-path drained".into(), + }, + ); } } } diff --git a/rust/otap-dataflow/crates/engine/src/shared/capability.rs b/rust/otap-dataflow/crates/engine/src/shared/capability.rs index e749db5ed0..05cc9159d6 100644 --- a/rust/otap-dataflow/crates/engine/src/shared/capability.rs +++ b/rust/otap-dataflow/crates/engine/src/shared/capability.rs @@ -7,6 +7,5 @@ //! Capability traits are defined by the `#[capability]` proc macro in //! per-capability modules under [`capability`](crate::capability). -// TODO: Add re-exports as capabilities are defined, e.g.: -// pub use crate::capability::bearer_token_provider::shared::BearerTokenProvider; -// pub use crate::capability::key_value_store::shared::KeyValueStore; +pub use crate::capability::no_op_stateful::shared::NoOpStateful; +pub use crate::capability::no_op_stateless::shared::NoOpStateless; diff --git a/rust/otap-dataflow/crates/engine/src/testing/exporter.rs b/rust/otap-dataflow/crates/engine/src/testing/exporter.rs index dbf043ba86..adecc5d7ef 100644 --- a/rust/otap-dataflow/crates/engine/src/testing/exporter.rs +++ b/rust/otap-dataflow/crates/engine/src/testing/exporter.rs @@ -405,6 +405,13 @@ pub fn create_exporter_from_factory( let mut node_config = NodeUserConfig::new_exporter_config(factory.name); node_config.config = config; let exporter_config = ExporterConfig::new("test_exporter"); - - (factory.create)(pipeline_ctx, node, Arc::new(node_config), &exporter_config) + let capabilities = crate::capability::registry::Capabilities::empty(); + + (factory.create)( + pipeline_ctx, + node, + Arc::new(node_config), + &exporter_config, + &capabilities, + ) } diff --git a/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs b/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs new file mode 100644 index 0000000000..f8d8e23a06 --- /dev/null +++ b/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs @@ -0,0 +1,3768 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end integration tests for the extension/capability wiring in +//! [`PipelineFactory::build`] and [`RuntimePipeline::run_forever`]. +//! +//! Each test instantiates a self-contained `PipelineFactory<()>` with +//! one synthetic receiver factory, one noop exporter factory, and one +//! or more synthetic extension factories, then exercises the build +//! and/or run paths to verify the engine's contract: +//! +//! 1. **Passive flow** — a passive extension's capability is resolvable +//! via `Capabilities::require_local::()` from within a receiver's +//! `create()` body. +//! 2. **Per-variant pruning** — a dual-registration bundle whose +//! consumers only call one of `require_local` / `require_shared` +//! drops the unused variant before the runtime pipeline is handed +//! off to `run_forever`. +//! 3. **Active spawn ordering** — an active extension's `start()` runs +//! BEFORE data-path nodes, but capability *construction* (the +//! receiver factory's `create()` body) runs *before* `start()` is +//! invoked at all (because `create()` is build-time). +//! 4. **Fail-fast on extension error** — an active extension whose +//! `start()` returns immediately aborts the pipeline; data-path +//! drain is not awaited. +//! 5. **Shutdown ordering** — extensions receive +//! `ExtensionControlMsg::Shutdown` only after data-path nodes have +//! drained. + +use async_trait::async_trait; +use otap_df_config::observed_state::{ObservedStateSettings, SendPolicy}; +use otap_df_config::pipeline::PipelineConfig; +use otap_df_config::policy::{ChannelCapacityPolicy, TelemetryPolicy}; +use otap_df_config::{DeployedPipelineKey, PipelineGroupId, PipelineId}; +use otap_df_engine::ExporterFactory; +use otap_df_engine::ExtensionFactory; +use otap_df_engine::ReceiverFactory; +use otap_df_engine::capability::no_op_stateful::NoOpStateful; +use otap_df_engine::capability::no_op_stateless::NoOpStateless; +use otap_df_engine::capability::registry::Capabilities; +use otap_df_engine::config::{ExporterConfig, ExtensionConfig, ReceiverConfig}; +use otap_df_engine::context::{ControllerContext, PipelineContext}; +use otap_df_engine::control::{ + ExtensionControlMsg, RuntimeControlMsg, pipeline_completion_msg_channel, + runtime_ctrl_msg_channel, +}; +use otap_df_engine::error::Error as EngineError; +use otap_df_engine::exporter::ExporterWrapper; +use otap_df_engine::extension::{EffectHandler, ExtensionBundle, ExtensionWrapper}; +use otap_df_engine::local::capability::NoOpStateful as LocalNoOpStateful; +use otap_df_engine::local::capability::NoOpStateless as LocalNoOpStateless; +use otap_df_engine::local::exporter as local_exp; +use otap_df_engine::local::processor as local_proc; +use otap_df_engine::local::receiver as local_recv; +use otap_df_engine::message::{ExporterInbox, Message}; +use otap_df_engine::processor::ProcessorWrapper; +use otap_df_engine::receiver::ReceiverWrapper; +use otap_df_engine::shared::capability::NoOpStateful as SharedNoOpStateful; +use otap_df_engine::shared::capability::NoOpStateless as SharedNoOpStateless; +use otap_df_engine::terminal_state::TerminalState; +use otap_df_engine::{PipelineFactory, extension_capabilities}; +use otap_df_state::store::ObservedStateStore; +use otap_df_telemetry::InternalTelemetrySystem; +use std::rc::Rc; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +// ───────────────────────────────────────────────────────────────────── +// Shared helpers — global probe registries thread per-test state into +// `static fn` factory bodies without unsafe code. +// ───────────────────────────────────────────────────────────────────── +// +// Receiver/extension factories are `static fn` pointers and cannot +// capture closures over per-test state. To pass `Arc` / +// `Arc>` handles into a factory body, each test: +// +// 1. Inserts its handles into a global `RECEIVER_PROBES` / +// `ACTIVE_EXT_PROBES` / `SHUTDOWN_RECORDING_PROBES` map keyed by a +// string token (the extension id is reused as the key). +// 2. Embeds the same key in the node/extension `config` JSON as +// `{"probe_key": ""}`. +// 3. Inside the factory body, looks up the key in the global +// registry and clones out the handles it needs. +// +// The registry is `Mutex>` (cheap; tests are not perf- +// critical), which avoids any `unsafe` and works fine across the +// rt-multi-thread bits we use in `run_pipeline_with_shutdown_after`. + +/// Per-test state for the [`ProbeReceiver`] factory. +/// +/// `sequence` selects which `Capabilities` accessors the factory body +/// invokes and in what order, so a single receiver factory can serve +/// every test variant (one-call, double-call one-shot enforcement, +/// stateful increment, etc.). Outcomes are captured in the various +/// counter / mutex fields. +#[derive(Clone)] +struct ReceiverProbe { + /// Bumped by 1 on each `create()` call. + create_calls: Arc, + /// Bumped by 1 if the FIRST claim on the binding succeeded. + first_call_succeeded: Arc, + /// Bumped by 1 if the SECOND claim returned + /// `Err(CapabilityAlreadyConsumed)` (one-shot enforcement + /// tests). For sequences with no second call, stays at 0. + second_call_already_consumed: Arc, + /// Captured `Display` of the second call's error (if any), so the + /// one-shot tests can do a substring assert on the variant name. + second_call_error_message: Arc>>, + /// Optional name returned by `NoOpStateless::name()` after a successful + /// `require_local::` call. + captured_name: Arc>>, + /// Return value from `NoOpStateful::increment()` if the sequence + /// invokes that path. Tests for shared state across nodes assert + /// on this to verify each node observed a distinct counter value + /// reflecting the shared `Arc`. + stateful_increment_return: Arc>>, + /// Selects which call sequence the factory body executes. + sequence: CallSequence, +} + +/// Enumerates the call sequences the [`ProbeReceiver`] factory can +/// execute against its `&Capabilities` input. Adding new variants is +/// strictly additive — existing tests keep their behavior. +#[derive(Clone, Copy, Debug)] +#[allow(dead_code)] // Some variants are reserved for future tests. +enum CallSequence { + /// `require_local::()` once. + Local, + /// `require_shared::()` once. + Shared, + /// `require_local::()`, then `require_local::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + RequireLocalTwice, + /// `require_local::()`, then `require_shared::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + RequireLocalThenRequireShared, + /// `require_local::()`, then `optional_local::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + RequireLocalThenOptionalLocal, + /// `require_local::()`, then `optional_shared::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + RequireLocalThenOptionalShared, + /// `optional_local::()`, then `require_local::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + OptionalLocalThenRequireLocal, + /// `optional_local::()`, then `optional_local::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + OptionalLocalThenOptionalLocal, + /// `optional_shared::()`, then `require_shared::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + OptionalSharedThenRequireShared, + /// `optional_shared::()`, then `optional_shared::()`. + /// Second call must error with `CapabilityAlreadyConsumed`. + OptionalSharedThenOptionalShared, + /// `require_local::()`, then call `.increment()` on the + /// boxed handle. Captures the return value for shared-state tests. + StatefulIncrement, + /// `require_shared::()`, then call `.increment()` on the + /// boxed shared handle (sync `&mut self` through the `Send` trait + /// variant). Captures the return value. + SharedStatefulIncrement, + /// `require_local::()`; the create() body keeps the + /// boxed handle alive by stashing it on the receiver, which then + /// invokes `.record(7).await` from inside its `start()` body — + /// exercising the async `&mut self` path on the local trait variant. + LocalStatefulRecordAsync, + /// `require_shared::()`; analogous to + /// [`Self::LocalStatefulRecordAsync`] but for the shared trait + /// variant (async `&mut self` with `Send` future). + SharedStatefulRecordAsync, + /// `require_shared::()` and call `count()` once at + /// build time, capturing the value into the probe's + /// `stateful_increment_return` slot. Used to assert that, before + /// the active extension's `start()` task runs, capability + /// consumers observe pre-mutation state. + SharedStatefulReadCount, + /// Don't claim anything. Used by tests where the receiver is just + /// a structural placeholder. + None, +} + +static RECEIVER_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn receiver_probes() -> &'static std::sync::Mutex> +{ + RECEIVER_PROBES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_receiver_probe(key: &str, probe: ReceiverProbe) { + let _ = receiver_probes() + .lock() + .expect("receiver probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_receiver_probe(key: &str) -> ReceiverProbe { + receiver_probes() + .lock() + .expect("receiver probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no ReceiverProbe registered for key '{key}'")) +} + +const PROBE_RECEIVER_URN: &str = "urn:test:receiver:probe"; +const PROBE_PROCESSOR_URN: &str = "urn:test:processor:probe"; +const PROBE_EXPORTER_URN: &str = "urn:test:exporter:probe"; +const NOOP_EXPORTER_URN: &str = "urn:test:exporter:noop"; +const PASSIVE_EXTENSION_URN: &str = "urn:test:passive_extension"; +const DUAL_EXTENSION_URN: &str = "urn:test:dual_extension"; +const ACTIVE_EXTENSION_URN: &str = "urn:test:active_extension"; +const FAILING_EXTENSION_URN: &str = "urn:test:failing_extension"; +const SHUTDOWN_RECORDING_EXTENSION_URN: &str = "urn:test:shutdown_recording_extension"; + +// ───────────────────────────────────────────────────────────────────── +// Probe receiver — exercises Capabilities API in create() +// ───────────────────────────────────────────────────────────────────── + +struct ProbeReceiver { + lifecycle: Option, + /// If set, `start()` calls `handle.record(7).await` on the local + /// stateful handle and stores the return value into the probe's + /// `stateful_increment_return` slot. Exercises async `&mut self` + /// on the local trait variant. + async_local_stateful: Option>, + /// As above but for the shared trait variant — exercises async + /// `&mut self` on the `Send` shared handle. + async_shared_stateful: Option>, + /// Probe key used to publish async record return values back to + /// the test (only consulted when one of the async fields is Some). + probe_key: Option, +} + +#[async_trait(?Send)] +impl local_recv::Receiver<()> for ProbeReceiver { + async fn start( + self: Box, + mut ctrl: local_recv::ControlChannel<()>, + _eh: local_recv::EffectHandler<()>, + ) -> Result { + if let Some(lc) = &self.lifecycle { + *lc.receiver_start_at.lock() = Some(Instant::now()); + } + + // Take the boxed async handles out of `self` and exercise them + // before entering the control loop. This proves the async + // `&mut self` codegen path is end-to-end invokable through a + // `Box` returned from the capability registry. + let probe_key = self.probe_key.clone(); + let mut this = self; + if let Some(mut handle) = this.async_local_stateful.take() { + let value = handle.record(7).await; + if let Some(key) = probe_key.as_deref() { + let probe = lookup_receiver_probe(key); + *probe.stateful_increment_return.lock() = Some(value); + } + } + if let Some(mut handle) = this.async_shared_stateful.take() { + let value = handle.record(11).await; + if let Some(key) = probe_key.as_deref() { + let probe = lookup_receiver_probe(key); + *probe.stateful_increment_return.lock() = Some(value); + } + } + + loop { + match ctrl.recv().await { + Ok(otap_df_engine::control::NodeControlMsg::Shutdown { .. }) | Err(_) => break, + Ok(_) => {} + } + } + if let Some(lc) = &this.lifecycle { + *lc.receiver_end_at.lock() = Some(Instant::now()); + } + Ok(TerminalState::default()) + } +} + +fn probe_receiver_create( + _pipeline_ctx: PipelineContext, + node: otap_df_engine::node::NodeId, + node_config: Arc, + receiver_config: &ReceiverConfig, + capabilities: &Capabilities, +) -> Result, otap_df_config::error::Error> { + let key = node_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in receiver node config"); + let probe = lookup_receiver_probe(key); + + let _ = probe.create_calls.fetch_add(1, Ordering::SeqCst); + + // Optional async stateful handles, populated by the call sequences + // that intentionally defer invocation to receiver `start()`. + let mut async_local_stateful: Option> = None; + let mut async_shared_stateful: Option> = None; + + fn record_already_consumed(probe: &ReceiverProbe, e: E) { + let _ = probe + .second_call_already_consumed + .fetch_add(1, Ordering::SeqCst); + *probe.second_call_error_message.lock() = Some(format!("{e}")); + } + + match probe.sequence { + CallSequence::None => {} + CallSequence::Local => { + if let Ok(handle) = capabilities.require_local::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + *probe.captured_name.lock() = Some(handle.name().to_owned()); + } + } + CallSequence::Shared => { + if capabilities.require_shared::().is_ok() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + } + CallSequence::RequireLocalTwice => { + if capabilities.require_local::().is_ok() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + // Second call on the same binding must error. + if let Err(e) = capabilities.require_local::() { + record_already_consumed(&probe, e); + } + } + CallSequence::RequireLocalThenRequireShared => { + if capabilities.require_local::().is_ok() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.require_shared::() { + record_already_consumed(&probe, e); + } + } + CallSequence::RequireLocalThenOptionalLocal => { + if capabilities.require_local::().is_ok() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.optional_local::() { + record_already_consumed(&probe, e); + } + } + CallSequence::RequireLocalThenOptionalShared => { + if capabilities.require_local::().is_ok() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.optional_shared::() { + record_already_consumed(&probe, e); + } + } + CallSequence::OptionalLocalThenRequireLocal => { + if matches!(capabilities.optional_local::(), Ok(Some(_))) { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.require_local::() { + record_already_consumed(&probe, e); + } + } + CallSequence::OptionalLocalThenOptionalLocal => { + if matches!(capabilities.optional_local::(), Ok(Some(_))) { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.optional_local::() { + record_already_consumed(&probe, e); + } + } + CallSequence::OptionalSharedThenRequireShared => { + if matches!(capabilities.optional_shared::(), Ok(Some(_))) { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.require_shared::() { + record_already_consumed(&probe, e); + } + } + CallSequence::OptionalSharedThenOptionalShared => { + if matches!(capabilities.optional_shared::(), Ok(Some(_))) { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + if let Err(e) = capabilities.optional_shared::() { + record_already_consumed(&probe, e); + } + } + CallSequence::StatefulIncrement => { + if let Ok(mut handle) = capabilities.require_local::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + let value = handle.increment(); + *probe.stateful_increment_return.lock() = Some(value); + } + } + CallSequence::SharedStatefulIncrement => { + if let Ok(mut handle) = capabilities.require_shared::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + let value = handle.increment(); + *probe.stateful_increment_return.lock() = Some(value); + } + } + CallSequence::LocalStatefulRecordAsync => { + if let Ok(handle) = capabilities.require_local::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + async_local_stateful = Some(handle); + } + } + CallSequence::SharedStatefulRecordAsync => { + if let Ok(handle) = capabilities.require_shared::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + async_shared_stateful = Some(handle); + } + } + CallSequence::SharedStatefulReadCount => { + if let Ok(handle) = capabilities.require_shared::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + let value = handle.count(); + *probe.stateful_increment_return.lock() = Some(value); + } + } + } + + Ok(ReceiverWrapper::local( + ProbeReceiver { + lifecycle: node_config + .config + .get("lifecycle_key") + .and_then(|v| v.as_str()) + .map(lookup_node_lifecycle_probe), + async_local_stateful, + async_shared_stateful, + probe_key: Some(key.to_owned()), + }, + node, + node_config, + receiver_config, + )) +} + +const PROBE_RECEIVER_FACTORY: ReceiverFactory<()> = ReceiverFactory { + name: PROBE_RECEIVER_URN, + create: probe_receiver_create, + wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Noop exporter +// ───────────────────────────────────────────────────────────────────── + +struct NoopExporter; + +#[async_trait(?Send)] +impl local_exp::Exporter<()> for NoopExporter { + async fn start( + self: Box, + mut inbox: ExporterInbox<()>, + _eh: local_exp::EffectHandler<()>, + ) -> Result { + loop { + if let Message::Control(otap_df_engine::control::NodeControlMsg::Shutdown { .. }) = + inbox.recv().await? + { + break; + } + } + Ok(TerminalState::default()) + } +} + +fn noop_exporter_create( + _pipeline_ctx: PipelineContext, + node: otap_df_engine::node::NodeId, + node_config: Arc, + exporter_config: &ExporterConfig, + _capabilities: &Capabilities, +) -> Result, otap_df_config::error::Error> { + Ok(ExporterWrapper::local( + NoopExporter, + node, + node_config, + exporter_config, + )) +} + +const NOOP_EXPORTER_FACTORY: ExporterFactory<()> = ExporterFactory { + name: NOOP_EXPORTER_URN, + create: noop_exporter_create, + wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Stateless no-op extension impl shared across local + shared variants. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct NoOpStatelessImpl { + name: &'static str, +} + +#[async_trait] +impl SharedNoOpStateless for NoOpStatelessImpl { + fn name(&self) -> &str { + self.name + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +#[async_trait(?Send)] +impl LocalNoOpStateless for NoOpStatelessImpl { + fn name(&self) -> &str { + self.name + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +/// Distinct second concrete type so dual-registration tests can satisfy +/// the builder's "local and shared variants must use different concrete +/// types" rule. Functionally identical to [`NoOpStatelessImpl`]. +#[derive(Clone)] +struct NoOpStatelessImplLocal { + name: &'static str, +} + +#[async_trait(?Send)] +impl LocalNoOpStateless for NoOpStatelessImplLocal { + fn name(&self) -> &str { + self.name + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +// ───────────────────────────────────────────────────────────────────── +// Passive (no-lifecycle) extension factory — provides NoOpStateless +// ───────────────────────────────────────────────────────────────────── + +fn passive_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .passive() + .cloned() + .shared::(NoOpStatelessImpl { + name: "passive-noop", + }) + .build() + .expect("passive extension bundle builds"); + Ok(bundle) +} + +const PASSIVE_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: PASSIVE_EXTENSION_URN, + description: "passive no-op extension providing NoOpStateless", + documentation_url: "", + capabilities: Some(extension_capabilities!( + shared: NoOpStatelessImpl => [NoOpStateless] + )), + create: passive_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Dual extension factory — registers BOTH local and shared variants so +// the per-variant pruning test has something to drop. +// ───────────────────────────────────────────────────────────────────── + +fn dual_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .passive() + .cloned() + .shared::(NoOpStatelessImpl { name: "dual-noop" }) + .local::(NoOpStatelessImplLocal { name: "dual-noop" }) + .build() + .expect("dual extension bundle builds"); + Ok(bundle) +} + +const DUAL_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: DUAL_EXTENSION_URN, + description: "passive extension with both local and shared NoOpStateless", + documentation_url: "", + capabilities: Some(extension_capabilities!( + (shared: NoOpStatelessImpl, local: NoOpStatelessImplLocal) => [NoOpStateless] + )), + create: dual_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Active extension — observable start() / shutdown() side effects +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct ActiveExtImpl { + started: Arc, + start_at: Arc>>, + shutdown_at: Arc>>, +} + +#[async_trait] +impl SharedNoOpStateless for ActiveExtImpl { + fn name(&self) -> &str { + "active-noop" + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +#[async_trait] +impl otap_df_engine::shared::extension::Extension for ActiveExtImpl { + async fn start( + self: Box, + mut ctrl: otap_df_engine::shared::extension::ControlChannel, + _eh: EffectHandler, + ) -> Result { + self.started.store(true, Ordering::SeqCst); + *self.start_at.lock() = Some(Instant::now()); + loop { + match ctrl.recv().await { + Ok(ExtensionControlMsg::Shutdown { .. }) | Err(_) => { + *self.shutdown_at.lock() = Some(Instant::now()); + break; + } + Ok(_) => {} + } + } + Ok(TerminalState::default()) + } +} + +#[derive(Clone)] +struct ActiveExtProbe { + started: Arc, + start_at: Arc>>, + shutdown_at: Arc>>, +} + +static ACTIVE_EXT_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn active_ext_probes() +-> &'static std::sync::Mutex> { + ACTIVE_EXT_PROBES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_active_ext_probe(key: &str, probe: ActiveExtProbe) { + let _ = active_ext_probes() + .lock() + .expect("active ext probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_active_ext_probe(key: &str) -> ActiveExtProbe { + active_ext_probes() + .lock() + .expect("active ext probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no ActiveExtProbe registered for key '{key}'")) +} + +fn active_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in active extension config"); + let probe = lookup_active_ext_probe(key); + let impl_ = ActiveExtImpl { + started: Arc::clone(&probe.started), + start_at: Arc::clone(&probe.start_at), + shutdown_at: Arc::clone(&probe.shutdown_at), + }; + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .active() + .shared::(impl_) + .build() + .expect("active extension bundle builds"); + Ok(bundle) +} + +const ACTIVE_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: ACTIVE_EXTENSION_URN, + description: "active extension exposing NoOpStateless with observable start/shutdown", + documentation_url: "", + capabilities: Some(extension_capabilities!( + shared: ActiveExtImpl => [NoOpStateless] + )), + create: active_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Active SHARED-COUNTER extension — same struct provides BOTH the +// extension lifecycle AND a `NoOpStateful` capability backed by an +// `Arc`. The `start()` task bumps the counter a fixed +// number of times before entering its event loop, so capability +// consumers can observe the active extension mutating shared state +// during its run. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct ActiveSharedCounterImpl { + counter: Arc, + bumps: u64, +} + +#[async_trait] +impl SharedNoOpStateful for ActiveSharedCounterImpl { + fn count(&self) -> u64 { + self.counter.load(Ordering::SeqCst) + } + fn increment(&mut self) -> u64 { + self.counter.fetch_add(1, Ordering::SeqCst) + 1 + } + fn reset(&mut self) { + self.counter.store(0, Ordering::SeqCst); + } + async fn record(&mut self, value: u64) -> u64 { + self.counter.fetch_add(value, Ordering::SeqCst) + value + } + fn last_recorded(&self) -> Option { + Some(self.counter.load(Ordering::SeqCst)) + } +} + +#[async_trait] +impl otap_df_engine::shared::extension::Extension for ActiveSharedCounterImpl { + async fn start( + self: Box, + mut ctrl: otap_df_engine::shared::extension::ControlChannel, + _eh: EffectHandler, + ) -> Result { + // Mutate shared state from inside the active task — the canonical + // pattern for an active extension publishing data to capability + // consumers (e.g., a token-refresh loop or a state-warmup task). + for _ in 0..self.bumps { + let _ = self.counter.fetch_add(1, Ordering::SeqCst); + } + loop { + match ctrl.recv().await { + Ok(ExtensionControlMsg::Shutdown { .. }) | Err(_) => break, + Ok(_) => {} + } + } + Ok(TerminalState::default()) + } +} + +const ACTIVE_SHARED_COUNTER_EXTENSION_URN: &str = "urn:test:active_shared_counter_extension"; + +fn active_shared_counter_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in active_shared_counter extension config"); + let bumps = user_config + .config + .get("bumps") + .and_then(|v| v.as_u64()) + .unwrap_or(3); + let probe = lookup_shared_counter_probe(key); + let impl_ = ActiveSharedCounterImpl { + counter: Arc::clone(&probe.counter), + bumps, + }; + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .active() + .shared::(impl_) + .build() + .expect("active shared counter extension bundle builds"); + Ok(bundle) +} + +const ACTIVE_SHARED_COUNTER_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: ACTIVE_SHARED_COUNTER_EXTENSION_URN, + description: "active extension whose start() task mutates an Arc exposed via NoOpStateful", + documentation_url: "", + capabilities: Some(extension_capabilities!( + shared: ActiveSharedCounterImpl => [NoOpStateful] + )), + create: active_shared_counter_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Failing extension — start() returns an error immediately +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct FailingExtImpl; + +#[async_trait] +impl SharedNoOpStateless for FailingExtImpl { + fn name(&self) -> &str { + "failing" + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +#[async_trait] +impl otap_df_engine::shared::extension::Extension for FailingExtImpl { + async fn start( + self: Box, + _ctrl: otap_df_engine::shared::extension::ControlChannel, + _eh: EffectHandler, + ) -> Result { + Err(EngineError::InternalError { + message: "synthetic extension start failure".into(), + }) + } +} + +fn failing_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .active() + .shared::(FailingExtImpl) + .build() + .expect("failing extension bundle builds"); + Ok(bundle) +} + +const FAILING_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: FAILING_EXTENSION_URN, + description: "active extension whose start() returns an error", + documentation_url: "", + capabilities: Some(extension_capabilities!( + shared: FailingExtImpl => [NoOpStateless] + )), + create: failing_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Shutdown-recording extension — captures the wall-clock instant at +// which Shutdown was received. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct ShutdownRecordingExtImpl { + shutdown_at: Arc>>, +} + +#[async_trait] +impl SharedNoOpStateless for ShutdownRecordingExtImpl { + fn name(&self) -> &str { + "shutdown-recorder" + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +#[async_trait] +impl otap_df_engine::shared::extension::Extension for ShutdownRecordingExtImpl { + async fn start( + self: Box, + mut ctrl: otap_df_engine::shared::extension::ControlChannel, + _eh: EffectHandler, + ) -> Result { + loop { + match ctrl.recv().await { + Ok(ExtensionControlMsg::Shutdown { .. }) | Err(_) => { + *self.shutdown_at.lock() = Some(Instant::now()); + break; + } + Ok(_) => {} + } + } + Ok(TerminalState::default()) + } +} + +#[derive(Clone)] +struct ShutdownRecordingProbe { + shutdown_at: Arc>>, +} + +static SHUTDOWN_RECORDING_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn shutdown_recording_probes() +-> &'static std::sync::Mutex> { + SHUTDOWN_RECORDING_PROBES + .get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_shutdown_recording_probe(key: &str, probe: ShutdownRecordingProbe) { + let _ = shutdown_recording_probes() + .lock() + .expect("shutdown recording probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_shutdown_recording_probe(key: &str) -> ShutdownRecordingProbe { + shutdown_recording_probes() + .lock() + .expect("shutdown recording probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no ShutdownRecordingProbe registered for key '{key}'")) +} + +fn shutdown_recording_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in shutdown_recording extension config"); + let probe = lookup_shutdown_recording_probe(key); + let impl_ = ShutdownRecordingExtImpl { + shutdown_at: Arc::clone(&probe.shutdown_at), + }; + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .active() + .shared::(impl_) + .build() + .expect("shutdown_recording extension bundle builds"); + Ok(bundle) +} + +const SHUTDOWN_RECORDING_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: SHUTDOWN_RECORDING_EXTENSION_URN, + description: "active extension that records the moment Shutdown is received", + documentation_url: "", + capabilities: Some(extension_capabilities!( + shared: ShutdownRecordingExtImpl => [NoOpStateless] + )), + create: shutdown_recording_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Dual-active extension — registers BOTH `.active().shared(...)` and +// `.active().local(Rc::new(...))`. Each side has its own observable +// `start()` so a test that consumes only one variant can assert the +// OTHER variant's wrapper was pruned (its `start()` never ran). +// ───────────────────────────────────────────────────────────────────── + +/// `!Send` Active extension impl. Functionally similar to +/// [`ActiveExtImpl`] but a separate concrete type so the builder's +/// "local and shared variants must use different concrete types" +/// invariant is satisfied. +#[derive(Clone)] +struct ActiveLocalExtImpl { + started: Arc, + start_at: Arc>>, + shutdown_at: Arc>>, +} + +#[async_trait(?Send)] +impl LocalNoOpStateless for ActiveLocalExtImpl { + fn name(&self) -> &str { + "active-local-noop" + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +#[async_trait(?Send)] +impl otap_df_engine::local::extension::Extension for ActiveLocalExtImpl { + async fn start( + self: Rc, + mut ctrl: otap_df_engine::local::extension::ControlChannel, + _eh: EffectHandler, + ) -> Result { + self.started.store(true, Ordering::SeqCst); + *self.start_at.lock() = Some(Instant::now()); + loop { + match ctrl.recv().await { + Ok(ExtensionControlMsg::Shutdown { .. }) | Err(_) => { + *self.shutdown_at.lock() = Some(Instant::now()); + break; + } + Ok(_) => {} + } + } + Ok(TerminalState::default()) + } +} + +const DUAL_ACTIVE_EXTENSION_URN: &str = "urn:test:dual_active_extension"; + +fn dual_active_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let local_key = user_config + .config + .get("local_probe_key") + .and_then(|v| v.as_str()) + .expect("local_probe_key present in dual_active extension config"); + let shared_key = user_config + .config + .get("shared_probe_key") + .and_then(|v| v.as_str()) + .expect("shared_probe_key present in dual_active extension config"); + let local_probe = lookup_active_ext_probe(local_key); + let shared_probe = lookup_active_ext_probe(shared_key); + + let local_impl = ActiveLocalExtImpl { + started: Arc::clone(&local_probe.started), + start_at: Arc::clone(&local_probe.start_at), + shutdown_at: Arc::clone(&local_probe.shutdown_at), + }; + let shared_impl = ActiveExtImpl { + started: Arc::clone(&shared_probe.started), + start_at: Arc::clone(&shared_probe.start_at), + shutdown_at: Arc::clone(&shared_probe.shutdown_at), + }; + + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .active() + .shared::(shared_impl) + .local::(Rc::new(local_impl)) + .build() + .expect("dual-active extension bundle builds"); + Ok(bundle) +} + +const DUAL_ACTIVE_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: DUAL_ACTIVE_EXTENSION_URN, + description: "active extension with separate active local + active shared variants", + documentation_url: "", + capabilities: Some(extension_capabilities!( + (shared: ActiveExtImpl, local: ActiveLocalExtImpl) => [NoOpStateless] + )), + create: dual_active_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Background extension — no capabilities, engine-driven event loop +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct BackgroundExtImpl { + started: Arc, + shutdown_at: Arc>>, +} + +#[async_trait] +impl otap_df_engine::shared::extension::Extension for BackgroundExtImpl { + async fn start( + self: Box, + mut ctrl: otap_df_engine::shared::extension::ControlChannel, + _eh: EffectHandler, + ) -> Result { + self.started.store(true, Ordering::SeqCst); + loop { + match ctrl.recv().await { + Ok(ExtensionControlMsg::Shutdown { .. }) | Err(_) => { + *self.shutdown_at.lock() = Some(Instant::now()); + break; + } + Ok(_) => {} + } + } + Ok(TerminalState::default()) + } +} + +const BACKGROUND_EXTENSION_URN: &str = "urn:test:background_extension"; + +#[derive(Clone)] +struct BackgroundProbe { + started: Arc, + shutdown_at: Arc>>, +} + +static BACKGROUND_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn background_probes() +-> &'static std::sync::Mutex> { + BACKGROUND_PROBES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_background_probe(key: &str, probe: BackgroundProbe) { + let _ = background_probes() + .lock() + .expect("background probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_background_probe(key: &str) -> BackgroundProbe { + background_probes() + .lock() + .expect("background probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no BackgroundProbe registered for key '{key}'")) +} + +fn background_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in background extension config"); + let probe = lookup_background_probe(key); + let impl_ = BackgroundExtImpl { + started: Arc::clone(&probe.started), + shutdown_at: Arc::clone(&probe.shutdown_at), + }; + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .background() + .shared::(impl_) + .build() + .expect("background extension bundle builds"); + Ok(bundle) +} + +const BACKGROUND_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: BACKGROUND_EXTENSION_URN, + description: "background extension — engine-driven event loop, no capabilities", + documentation_url: "", + // `None` is the engine's runtime signal that this is a Background + // extension: `register_into` skips capability registration, and + // pruning treats this bundle as "always kept". + capabilities: None, + create: background_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Shared-counter extension — provides NoOpStateful via passive Cloned; +// holds an `Arc` counter so cloned consumers share state. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct SharedCounterImpl { + counter: Arc, +} + +#[async_trait] +impl SharedNoOpStateful for SharedCounterImpl { + fn count(&self) -> u64 { + self.counter.load(Ordering::SeqCst) + } + fn increment(&mut self) -> u64 { + // `&mut self` is fine — interior mutability via the `Arc` + // means clones of `SharedCounterImpl` all observe the same + // underlying value, demonstrating cross-consumer state sharing + // when the impl explicitly opts in. + self.counter.fetch_add(1, Ordering::SeqCst) + 1 + } + fn reset(&mut self) { + self.counter.store(0, Ordering::SeqCst); + } + async fn record(&mut self, value: u64) -> u64 { + self.counter.fetch_add(value, Ordering::SeqCst) + value + } + fn last_recorded(&self) -> Option { + Some(self.counter.load(Ordering::SeqCst)) + } +} + +#[async_trait(?Send)] +impl LocalNoOpStateful for SharedCounterImpl { + fn count(&self) -> u64 { + self.counter.load(Ordering::SeqCst) + } + fn increment(&mut self) -> u64 { + self.counter.fetch_add(1, Ordering::SeqCst) + 1 + } + fn reset(&mut self) { + self.counter.store(0, Ordering::SeqCst); + } + async fn record(&mut self, value: u64) -> u64 { + self.counter.fetch_add(value, Ordering::SeqCst) + value + } + fn last_recorded(&self) -> Option { + Some(self.counter.load(Ordering::SeqCst)) + } +} + +const SHARED_COUNTER_EXTENSION_URN: &str = "urn:test:shared_counter_extension"; + +#[derive(Clone)] +struct SharedCounterProbe { + counter: Arc, +} + +static SHARED_COUNTER_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn shared_counter_probes() +-> &'static std::sync::Mutex> { + SHARED_COUNTER_PROBES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_shared_counter_probe(key: &str, probe: SharedCounterProbe) { + let _ = shared_counter_probes() + .lock() + .expect("shared counter probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_shared_counter_probe(key: &str) -> SharedCounterProbe { + shared_counter_probes() + .lock() + .expect("shared counter probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no SharedCounterProbe registered for key '{key}'")) +} + +fn shared_counter_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in shared_counter extension config"); + let probe = lookup_shared_counter_probe(key); + let impl_ = SharedCounterImpl { + counter: Arc::clone(&probe.counter), + }; + // `.passive().cloned()` — each consumer gets its own `Clone` of the + // prototype. The `Arc` field means clones all point at + // the same underlying counter, which is the explicit + // share-state-via-`Arc` pattern documented in the architecture. + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .passive() + .cloned() + .local::(impl_) + .build() + .expect("shared counter extension bundle builds"); + Ok(bundle) +} + +const SHARED_COUNTER_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: SHARED_COUNTER_EXTENSION_URN, + description: "passive cloned extension that shares an Arc counter across consumers", + documentation_url: "", + capabilities: Some(extension_capabilities!( + local: SharedCounterImpl => [NoOpStateful] + )), + create: shared_counter_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Shared-counter extension (shared variant) — same `SharedCounterImpl` +// registered under `.passive().cloned().shared(...)` so tests can +// exercise the `require_shared::()` path (sync + async +// `&mut self` on the `Send` shared trait variant). +// ───────────────────────────────────────────────────────────────────── + +const SHARED_COUNTER_SHARED_EXTENSION_URN: &str = "urn:test:shared_counter_shared_extension"; + +fn shared_counter_shared_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in shared_counter_shared extension config"); + let probe = lookup_shared_counter_probe(key); + let impl_ = SharedCounterImpl { + counter: Arc::clone(&probe.counter), + }; + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .passive() + .cloned() + .shared::(impl_) + .build() + .expect("shared counter (shared variant) extension bundle builds"); + Ok(bundle) +} + +const SHARED_COUNTER_SHARED_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: SHARED_COUNTER_SHARED_EXTENSION_URN, + description: "passive cloned extension exposing SharedCounterImpl via the shared trait variant", + documentation_url: "", + capabilities: Some(extension_capabilities!( + shared: SharedCounterImpl => [NoOpStateful] + )), + create: shared_counter_shared_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Constructed extension — `.passive().constructed(closure)` so each +// consumer triggers a fresh instance from the user-supplied closure. +// The closure increments a counter so the test can verify it ran once +// per consumer. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct ConstructedNoOpImpl; + +#[async_trait(?Send)] +impl LocalNoOpStateless for ConstructedNoOpImpl { + fn name(&self) -> &str { + "constructed-noop" + } + fn echo(&self, value: u64) -> u64 { + value + } + async fn ping(&self) -> u64 { + 0 + } + async fn echo_async(&self, value: String) -> String { + value + } +} + +const CONSTRUCTED_EXTENSION_URN: &str = "urn:test:constructed_extension"; + +#[derive(Clone)] +struct ConstructedProbe { + /// Bumped by 1 every time the user-supplied factory closure runs. + closure_invocations: Arc, +} + +static CONSTRUCTED_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn constructed_probes() +-> &'static std::sync::Mutex> { + CONSTRUCTED_PROBES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_constructed_probe(key: &str, probe: ConstructedProbe) { + let _ = constructed_probes() + .lock() + .expect("constructed probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_constructed_probe(key: &str) -> ConstructedProbe { + constructed_probes() + .lock() + .expect("constructed probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no ConstructedProbe registered for key '{key}'")) +} + +fn constructed_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + let key = user_config + .config + .get("probe_key") + .and_then(|v| v.as_str()) + .expect("probe_key present in constructed extension config"); + let probe = lookup_constructed_probe(key); + // `.passive().constructed(closure)` — the closure is invoked once + // per consumer at `Capabilities::require_local` time. Each consumer + // gets a fresh `ConstructedNoOpImpl` value, demonstrating + // per-consumer instantiation. + let counter = Arc::clone(&probe.closure_invocations); + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .passive() + .constructed() + .local::(move || { + let _ = counter.fetch_add(1, Ordering::SeqCst); + ConstructedNoOpImpl + }) + .build() + .expect("constructed extension bundle builds"); + Ok(bundle) +} + +const CONSTRUCTED_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: CONSTRUCTED_EXTENSION_URN, + description: "passive constructed extension; closure runs once per consumer", + documentation_url: "", + capabilities: Some(extension_capabilities!( + local: ConstructedNoOpImpl => [NoOpStateless] + )), + create: constructed_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Rc-counter extension (LOCAL ONLY) — proves shared mutable state via +// `Rc>` is observable across multiple consumers when the +// impl is registered with `.passive().cloned()`. `Rc` is `!Send` so +// this impl can only be wired through the local trait variant. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone)] +struct RcCounterImpl { + counter: Rc>, +} + +#[async_trait(?Send)] +impl LocalNoOpStateful for RcCounterImpl { + fn count(&self) -> u64 { + *self.counter.borrow() + } + fn increment(&mut self) -> u64 { + let mut c = self.counter.borrow_mut(); + *c += 1; + *c + } + fn reset(&mut self) { + *self.counter.borrow_mut() = 0; + } + async fn record(&mut self, value: u64) -> u64 { + let mut c = self.counter.borrow_mut(); + *c += value; + *c + } + fn last_recorded(&self) -> Option { + Some(*self.counter.borrow()) + } +} + +const RC_COUNTER_EXTENSION_URN: &str = "urn:test:rc_counter_extension"; + +fn rc_counter_extension_create( + _pipeline_ctx: PipelineContext, + name: otap_df_config::ExtensionId, + user_config: Arc, + extension_config: &ExtensionConfig, +) -> Result { + // The prototype owns one `Rc>`; `.passive().cloned()` + // hands each consumer a shallow `Clone` of the prototype, and + // `Rc::clone` keeps the inner `RefCell` shared across consumers. + let impl_ = RcCounterImpl { + counter: Rc::new(std::cell::RefCell::new(0)), + }; + let bundle = ExtensionWrapper::builder(name, user_config, extension_config) + .passive() + .cloned() + .local::(impl_) + .build() + .expect("rc counter extension bundle builds"); + Ok(bundle) +} + +const RC_COUNTER_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { + name: RC_COUNTER_EXTENSION_URN, + description: "passive cloned extension whose impl shares an Rc> across consumers", + documentation_url: "", + capabilities: Some(extension_capabilities!( + local: RcCounterImpl => [NoOpStateful] + )), + create: rc_counter_extension_create, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// Node lifecycle probe — captures `start()` entry / exit timestamps for +// the probe receiver, processor, and exporter so lifecycle ordering +// tests can compare against extension start/shutdown timestamps. +// Separate from `ReceiverProbe` to keep the existing capability-call +// probe focused on its job. +// ───────────────────────────────────────────────────────────────────── + +#[derive(Clone, Default)] +struct NodeLifecycleProbe { + receiver_start_at: Arc>>, + receiver_end_at: Arc>>, + processor_first_call_at: Arc>>, + processor_end_at: Arc>>, + exporter_start_at: Arc>>, + exporter_end_at: Arc>>, +} + +static NODE_LIFECYCLE_PROBES: std::sync::OnceLock< + std::sync::Mutex>, +> = std::sync::OnceLock::new(); + +fn node_lifecycle_probes() +-> &'static std::sync::Mutex> { + NODE_LIFECYCLE_PROBES.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +fn register_node_lifecycle_probe(key: &str, probe: NodeLifecycleProbe) { + let _ = node_lifecycle_probes() + .lock() + .expect("node lifecycle probes mutex poisoned") + .insert(key.to_owned(), probe); +} + +fn lookup_node_lifecycle_probe(key: &str) -> NodeLifecycleProbe { + node_lifecycle_probes() + .lock() + .expect("node lifecycle probes mutex poisoned") + .get(key) + .cloned() + .unwrap_or_else(|| panic!("no NodeLifecycleProbe registered for key '{key}'")) +} + +// ───────────────────────────────────────────────────────────────────── +// Probe processor — exercises Capabilities API in create() and records +// lifecycle timestamps. Pass-through for pdata; loops on Shutdown. +// ───────────────────────────────────────────────────────────────────── + +struct ProbeProcessor { + lifecycle: Option, +} + +#[async_trait(?Send)] +impl local_proc::Processor<()> for ProbeProcessor { + async fn process( + &mut self, + msg: Message<()>, + _eh: &mut local_proc::EffectHandler<()>, + ) -> Result<(), EngineError> { + if let Some(lc) = &self.lifecycle { + let mut slot = lc.processor_first_call_at.lock(); + if slot.is_none() { + *slot = Some(Instant::now()); + } + } + if let Message::Control(otap_df_engine::control::NodeControlMsg::Shutdown { .. }) = msg { + if let Some(lc) = &self.lifecycle { + *lc.processor_end_at.lock() = Some(Instant::now()); + } + } + Ok(()) + } +} + +fn probe_processor_create( + _pipeline_ctx: PipelineContext, + node: otap_df_engine::node::NodeId, + node_config: Arc, + processor_config: &otap_df_engine::config::ProcessorConfig, + capabilities: &Capabilities, +) -> Result, otap_df_config::error::Error> { + let probe_key = node_config.config.get("probe_key").and_then(|v| v.as_str()); + let lifecycle_key = node_config + .config + .get("lifecycle_key") + .and_then(|v| v.as_str()); + let optional_only = node_config + .config + .get("optional_only") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if let Some(key) = probe_key { + let probe = lookup_receiver_probe(key); + let _ = probe.create_calls.fetch_add(1, Ordering::SeqCst); + if optional_only { + // Validate the "optional capability absent" path: with no + // extension declared, optional_local must return Ok(None). + let result = capabilities.optional_local::(); + if matches!(result, Ok(None)) { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + } else if let Ok(handle) = capabilities.require_local::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + *probe.captured_name.lock() = Some(handle.name().to_owned()); + } + } + + let lifecycle = lifecycle_key.map(lookup_node_lifecycle_probe); + Ok(ProcessorWrapper::local( + ProbeProcessor { lifecycle }, + node, + node_config, + processor_config, + )) +} + +const PROBE_PROCESSOR_FACTORY: otap_df_engine::ProcessorFactory<()> = + otap_df_engine::ProcessorFactory { + name: PROBE_PROCESSOR_URN, + create: probe_processor_create, + wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, + validate_config: otap_df_config::validation::no_config, + }; + +// ───────────────────────────────────────────────────────────────────── +// Probe exporter — exercises Capabilities API in create() and records +// lifecycle timestamps. Identical lifecycle to NoopExporter otherwise. +// ───────────────────────────────────────────────────────────────────── + +struct ProbeExporter { + lifecycle: Option, +} + +#[async_trait(?Send)] +impl local_exp::Exporter<()> for ProbeExporter { + async fn start( + self: Box, + mut inbox: ExporterInbox<()>, + _eh: local_exp::EffectHandler<()>, + ) -> Result { + if let Some(lc) = &self.lifecycle { + *lc.exporter_start_at.lock() = Some(Instant::now()); + } + loop { + if let Message::Control(otap_df_engine::control::NodeControlMsg::Shutdown { .. }) = + inbox.recv().await? + { + break; + } + } + if let Some(lc) = &self.lifecycle { + *lc.exporter_end_at.lock() = Some(Instant::now()); + } + Ok(TerminalState::default()) + } +} + +fn probe_exporter_create( + _pipeline_ctx: PipelineContext, + node: otap_df_engine::node::NodeId, + node_config: Arc, + exporter_config: &ExporterConfig, + capabilities: &Capabilities, +) -> Result, otap_df_config::error::Error> { + let probe_key = node_config.config.get("probe_key").and_then(|v| v.as_str()); + let lifecycle_key = node_config + .config + .get("lifecycle_key") + .and_then(|v| v.as_str()); + let optional_only = node_config + .config + .get("optional_only") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if let Some(key) = probe_key { + let probe = lookup_receiver_probe(key); + let _ = probe.create_calls.fetch_add(1, Ordering::SeqCst); + if optional_only { + let result = capabilities.optional_local::(); + if matches!(result, Ok(None)) { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + } + } else if let Ok(handle) = capabilities.require_local::() { + let _ = probe.first_call_succeeded.fetch_add(1, Ordering::SeqCst); + *probe.captured_name.lock() = Some(handle.name().to_owned()); + } + } + + let lifecycle = lifecycle_key.map(lookup_node_lifecycle_probe); + Ok(ExporterWrapper::local( + ProbeExporter { lifecycle }, + node, + node_config, + exporter_config, + )) +} + +const PROBE_EXPORTER_FACTORY: ExporterFactory<()> = ExporterFactory { + name: PROBE_EXPORTER_URN, + create: probe_exporter_create, + wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, + validate_config: otap_df_config::validation::no_config, +}; + +// ───────────────────────────────────────────────────────────────────── +// PipelineFactory<()> wiring all the test factories +// ───────────────────────────────────────────────────────────────────── + +const RECEIVER_FACTORIES: &[ReceiverFactory<()>] = &[PROBE_RECEIVER_FACTORY]; +const PROCESSOR_FACTORIES: &[otap_df_engine::ProcessorFactory<()>] = &[PROBE_PROCESSOR_FACTORY]; +const EXPORTER_FACTORIES: &[ExporterFactory<()>] = &[NOOP_EXPORTER_FACTORY, PROBE_EXPORTER_FACTORY]; +const EXTENSION_FACTORIES: &[ExtensionFactory] = &[ + PASSIVE_EXTENSION_FACTORY, + DUAL_EXTENSION_FACTORY, + ACTIVE_EXTENSION_FACTORY, + ACTIVE_SHARED_COUNTER_EXTENSION_FACTORY, + FAILING_EXTENSION_FACTORY, + SHUTDOWN_RECORDING_EXTENSION_FACTORY, + DUAL_ACTIVE_EXTENSION_FACTORY, + BACKGROUND_EXTENSION_FACTORY, + SHARED_COUNTER_EXTENSION_FACTORY, + SHARED_COUNTER_SHARED_EXTENSION_FACTORY, + CONSTRUCTED_EXTENSION_FACTORY, + RC_COUNTER_EXTENSION_FACTORY, +]; + +static TEST_PIPELINE_FACTORY: PipelineFactory<()> = PipelineFactory::new( + RECEIVER_FACTORIES, + PROCESSOR_FACTORIES, + EXPORTER_FACTORIES, + EXTENSION_FACTORIES, +); + +// ───────────────────────────────────────────────────────────────────── +// Pipeline-build / run helpers shared across tests +// ───────────────────────────────────────────────────────────────────── + +fn fresh_pipeline_context() -> ( + InternalTelemetrySystem, + PipelineContext, + otap_df_telemetry::registry::EntityKey, +) { + let telemetry_system = InternalTelemetrySystem::default(); + let registry = telemetry_system.registry(); + let controller_ctx = ControllerContext::new(registry); + let ctx = controller_ctx.pipeline_context_with( + PipelineGroupId::from("test-group"), + PipelineId::from("test-pipeline"), + 0, + 1, + 0, + ); + let entity_key = ctx.register_pipeline_entity(); + (telemetry_system, ctx, entity_key) +} + +fn run_pipeline_with_shutdown_after( + runtime_pipeline: otap_df_engine::runtime_pipeline::RuntimePipeline<()>, + pipeline_ctx: PipelineContext, + pipeline_entity_key: otap_df_telemetry::registry::EntityKey, + telemetry_system: InternalTelemetrySystem, + shutdown_after: Duration, +) -> Result, EngineError> { + let channel_capacity_policy = ChannelCapacityPolicy::default(); + let (runtime_ctrl_tx, runtime_ctrl_rx) = + runtime_ctrl_msg_channel(channel_capacity_policy.control.pipeline); + let (pipeline_completion_tx, pipeline_completion_rx) = + pipeline_completion_msg_channel(channel_capacity_policy.control.completion); + + let registry = telemetry_system.registry(); + let observed_state_store = ObservedStateStore::new(&ObservedStateSettings::default(), registry); + let pipeline_key = DeployedPipelineKey { + pipeline_group_id: pipeline_ctx.pipeline_group_id(), + pipeline_id: pipeline_ctx.pipeline_id(), + core_id: 0, + deployment_generation: 0, + }; + let metrics_reporter = telemetry_system.reporter(); + let event_reporter = observed_state_store.reporter(SendPolicy::default()); + + let runtime_ctrl_tx_for_shutdown = runtime_ctrl_tx.clone(); + let _shutdown_handle = std::thread::spawn(move || { + std::thread::sleep(shutdown_after); + let deadline = Instant::now() + Duration::from_millis(200); + let _ = runtime_ctrl_tx_for_shutdown.try_send(RuntimeControlMsg::Shutdown { + deadline, + reason: "test-driven shutdown".to_owned(), + }); + }); + + let _pipeline_entity_guard = otap_df_engine::entity_context::set_pipeline_entity_key( + pipeline_ctx.metrics_registry(), + pipeline_entity_key, + ); + let (_memory_pressure_tx, memory_pressure_rx) = tokio::sync::watch::channel( + otap_df_engine::memory_limiter::MemoryPressureChanged::initial(), + ); + runtime_pipeline.run_forever( + pipeline_key, + pipeline_ctx, + event_reporter, + metrics_reporter, + Duration::from_secs(1), + memory_pressure_rx, + runtime_ctrl_tx, + runtime_ctrl_rx, + pipeline_completion_tx, + pipeline_completion_rx, + ) +} + +fn build_test_runtime_pipeline( + yaml: &str, +) -> ( + otap_df_engine::runtime_pipeline::RuntimePipeline<()>, + PipelineContext, + otap_df_telemetry::registry::EntityKey, + InternalTelemetrySystem, +) { + let config = PipelineConfig::from_yaml("test-group".into(), "test-pipeline".into(), yaml) + .expect("yaml config parses + validates"); + let (telemetry_system, pipeline_ctx, entity_key) = fresh_pipeline_context(); + let runtime_pipeline = TEST_PIPELINE_FACTORY + .build( + pipeline_ctx.clone(), + config, + ChannelCapacityPolicy::default(), + TelemetryPolicy::default(), + None, + None, + ) + .expect("pipeline builds"); + (runtime_pipeline, pipeline_ctx, entity_key, telemetry_system) +} + +struct ReceiverProbeHandles { + create_calls: Arc, + first_call_succeeded: Arc, + second_call_already_consumed: Arc, + second_call_error_message: Arc>>, + captured_name: Arc>>, + stateful_increment_return: Arc>>, +} + +fn make_probe(key: &str, sequence: CallSequence) -> ReceiverProbeHandles { + let create_calls = Arc::new(AtomicUsize::new(0)); + let first_call_succeeded = Arc::new(AtomicUsize::new(0)); + let second_call_already_consumed = Arc::new(AtomicUsize::new(0)); + let second_call_error_message: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let captured_name: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let stateful_increment_return: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_receiver_probe( + key, + ReceiverProbe { + create_calls: Arc::clone(&create_calls), + first_call_succeeded: Arc::clone(&first_call_succeeded), + second_call_already_consumed: Arc::clone(&second_call_already_consumed), + second_call_error_message: Arc::clone(&second_call_error_message), + captured_name: Arc::clone(&captured_name), + stateful_increment_return: Arc::clone(&stateful_increment_return), + sequence, + }, + ); + ReceiverProbeHandles { + create_calls, + first_call_succeeded, + second_call_already_consumed, + second_call_error_message, + captured_name, + stateful_increment_return, + } +} + +// ───────────────────────────────────────────────────────────────────── +// Test 1 — passive extension provides NoOpStateless to a node consumer +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_passive_extension_provides_capability_to_node() { + let key = "passive-test"; + let probe = make_probe(key, CallSequence::Local); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "noop-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + noop-ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!( + probe.create_calls.load(Ordering::SeqCst), + 1, + "create() called once" + ); + assert_eq!( + probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "require_local::() succeeded" + ); + assert_eq!( + probe.captured_name.lock().as_deref(), + Some("passive-noop"), + "name from passive-cloned extension is observable to the consumer" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 2 — dual ACTIVE local+shared; receiver consumes only local; +// assert the shared variant's wrapper was pruned (start() +// never ran). This is Category 3a in lib.rs — the unused +// variant is silently dropped while the consumed variant +// is kept and spawned. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_dual_active_unused_variant_is_pruned_and_never_starts() { + let receiver_key = "dual-active-recv"; + let probe = make_probe(receiver_key, CallSequence::Local); + + // Two distinct probes — one for the local Active variant, one for + // the shared Active variant. After the run we'll assert local + // started and shared did NOT (because pruning dropped its wrapper + // before `run_forever` could spawn it). + let local_probe_key = "dual-active-local-probe"; + let local_started = Arc::new(AtomicBool::new(false)); + let local_start_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let local_shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_active_ext_probe( + local_probe_key, + ActiveExtProbe { + started: Arc::clone(&local_started), + start_at: Arc::clone(&local_start_at), + shutdown_at: Arc::clone(&local_shutdown_at), + }, + ); + + let shared_probe_key = "dual-active-shared-probe"; + let shared_started = Arc::new(AtomicBool::new(false)); + let shared_start_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let shared_shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_active_ext_probe( + shared_probe_key, + ActiveExtProbe { + started: Arc::clone(&shared_started), + start_at: Arc::clone(&shared_start_at), + shutdown_at: Arc::clone(&shared_shutdown_at), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + capabilities: + no_op_stateless: "dual-active-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + dual-active-ext: + type: "{DUAL_ACTIVE_EXTENSION_URN}" + config: + local_probe_key: "{local_probe_key}" + shared_probe_key: "{shared_probe_key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + // Receiver consumed `require_local` at build time, before any + // spawn. Neither variant has started yet. + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert!(!local_started.load(Ordering::SeqCst)); + assert!(!shared_started.load(Ordering::SeqCst)); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline must shut down cleanly: {result:?}" + ); + + // Consumed local variant: kept and spawned. + assert!( + local_started.load(Ordering::SeqCst), + "local variant was consumed by the receiver, so its wrapper must be \ + kept and `start()` must run" + ); + assert!( + local_shutdown_at.lock().is_some(), + "local variant must observe Shutdown" + ); + + // Unused shared variant: pruned and NEVER spawned. + assert!( + !shared_started.load(Ordering::SeqCst), + "shared variant of the dual-active extension was NOT consumed, so \ + its wrapper must be pruned and `start()` must never run" + ); + assert!( + shared_start_at.lock().is_none(), + "shared variant start_at must remain None" + ); + assert!( + shared_shutdown_at.lock().is_none(), + "shared variant shutdown_at must remain None" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 3 — active extension start() runs after build (capability +// construction in receiver's create() observes start()=false) +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_active_extension_start_runs_after_create() { + let receiver_key = "active-recv"; + let ext_key = "active-ext-probe"; + let probe = make_probe(receiver_key, CallSequence::Local); + + let started = Arc::new(AtomicBool::new(false)); + let start_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_active_ext_probe( + ext_key, + ActiveExtProbe { + started: Arc::clone(&started), + start_at: Arc::clone(&start_at), + shutdown_at: Arc::clone(&shutdown_at), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + capabilities: + no_op_stateless: "active-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + active-ext: + type: "{ACTIVE_EXTENSION_URN}" + config: + probe_key: "{ext_key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + // Capability construction happened during build, before any task + // was spawned — assert start() is still false at this point. + assert_eq!(probe.create_calls.load(Ordering::SeqCst), 1); + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert!( + !started.load(Ordering::SeqCst), + "start() must not have been called before run_forever spawns it" + ); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + assert!( + started.load(Ordering::SeqCst), + "start() must run before pipeline returns" + ); + assert!( + shutdown_at.lock().is_some(), + "extension must observe Shutdown" + ); + assert!( + start_at.lock().is_some(), + "extension must record start timestamp" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 4 — fail-fast on extension error +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_active_extension_failure_aborts_pipeline_fast() { + let receiver_key = "failing-recv"; + let _probe = make_probe(receiver_key, CallSequence::Local); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + capabilities: + no_op_stateless: "failing-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + failing-ext: + type: "{FAILING_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + // 60s shutdown grace; if fail-fast works the test returns far sooner. + let started_at = Instant::now(); + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_secs(60), + ); + let elapsed = started_at.elapsed(); + assert!( + result.is_err(), + "pipeline should abort fast when an active extension errors at start: {result:?}" + ); + assert!( + elapsed < Duration::from_secs(5), + "pipeline should have failed fast (well under 5s), but took {elapsed:?}" + ); + let err = result.err().unwrap(); + let msg = format!("{err}"); + assert!( + msg.contains("synthetic extension start failure"), + "error should propagate the extension's failure reason, got: {msg}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 5 — shutdown ordering: extension records Shutdown timestamp +// inside the pipeline run window +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_extension_receives_shutdown_within_run_window() { + let receiver_key = "shutdown-recv"; + let ext_key = "shutdown-ext-probe"; + let _probe = make_probe(receiver_key, CallSequence::Local); + + let shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_shutdown_recording_probe( + ext_key, + ShutdownRecordingProbe { + shutdown_at: Arc::clone(&shutdown_at), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + capabilities: + no_op_stateless: "rec-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + rec-ext: + type: "{SHUTDOWN_RECORDING_EXTENSION_URN}" + config: + probe_key: "{ext_key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + let pipeline_start = Instant::now(); + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + let pipeline_end = Instant::now(); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + + let observed_shutdown = shutdown_at + .lock() + .expect("extension must record Shutdown timestamp"); + // The extension receives Shutdown after the data-path has drained, + // but the precise interleaving is implementation-defined and the + // timestamps may be very close. The contract we assert here is + // that the extension *did* observe Shutdown and did so within the + // pipeline run window. + assert!( + observed_shutdown >= pipeline_start && observed_shutdown <= pipeline_end, + "extension shutdown timestamp {observed_shutdown:?} must be within \ + pipeline run window [{pipeline_start:?}, {pipeline_end:?}]" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 6 — one-shot enforcement: require_local twice +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_require_local_twice_errors() { + let key = "one-shot-rl-rl"; + let probe = make_probe(key, CallSequence::RequireLocalTwice); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!( + probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "first require_local must succeed" + ); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "second require_local must error with CapabilityAlreadyConsumed" + ); + let msg = probe + .second_call_error_message + .lock() + .clone() + .expect("second call must record an error"); + assert!( + msg.to_ascii_lowercase().contains("already") || msg.contains("AlreadyConsumed"), + "expected an 'already consumed' error message, got: {msg}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 7 — one-shot enforcement: require_local then require_shared +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_require_local_then_require_shared_errors() { + let key = "one-shot-rl-rs"; + let probe = make_probe(key, CallSequence::RequireLocalThenRequireShared); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "claiming local then shared on the same binding must error on the second call" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 8 — one-shot enforcement: require_local then optional_local +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_require_local_then_optional_local_errors() { + let key = "one-shot-rl-ol"; + let probe = make_probe(key, CallSequence::RequireLocalThenOptionalLocal); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "optional_local after require_local on same binding must error" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 9 — one-shot enforcement: require_local then optional_shared +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_require_local_then_optional_shared_errors() { + let key = "one-shot-rl-os"; + let probe = make_probe(key, CallSequence::RequireLocalThenOptionalShared); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "optional_shared after require_local on same binding must error" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 10 — Background extension is kept (no warning) and runs even +// though no node binds any of its capabilities (it has none). +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_background_extension_runs_without_node_bindings() { + // The receiver claims nothing — pipeline has no capability bindings + // anywhere. We're testing the Background lifecycle in isolation. + let receiver_key = "bg-receiver"; + let _probe = make_probe(receiver_key, CallSequence::None); + + let bg_key = "bg-probe"; + let started = Arc::new(AtomicBool::new(false)); + let shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_background_probe( + bg_key, + BackgroundProbe { + started: Arc::clone(&started), + shutdown_at: Arc::clone(&shutdown_at), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + bg: + type: "{BACKGROUND_EXTENSION_URN}" + config: + probe_key: "{bg_key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline with a Background extension should shut down cleanly: {result:?}" + ); + assert!( + started.load(Ordering::SeqCst), + "Background extension's start() must run even though no node binds it" + ); + assert!( + shutdown_at.lock().is_some(), + "Background extension must observe Shutdown" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 11 — Background extension survives pruning even when an +// unbound *non-background* extension is dropped. +// Two extensions in the same pipeline; receiver binds neither. +// Asserts contrasting outcomes prove the BG special-case. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_background_kept_while_unbound_active_dropped() { + let receiver_key = "contrast-receiver"; + let _probe = make_probe(receiver_key, CallSequence::None); + + let bg_key = "contrast-bg"; + let bg_started = Arc::new(AtomicBool::new(false)); + let bg_shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_background_probe( + bg_key, + BackgroundProbe { + started: Arc::clone(&bg_started), + shutdown_at: Arc::clone(&bg_shutdown_at), + }, + ); + + let active_key = "contrast-active"; + let active_started = Arc::new(AtomicBool::new(false)); + let active_start_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let active_shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_active_ext_probe( + active_key, + ActiveExtProbe { + started: Arc::clone(&active_started), + start_at: Arc::clone(&active_start_at), + shutdown_at: Arc::clone(&active_shutdown_at), + }, + ); + + // Both a Background and an Active extension are declared. The + // receiver binds NEITHER (its `capabilities:` block is omitted). + // Expected outcomes: + // - Background bundle: kept (Category 1) → start() runs. + // - Active bundle: dropped (Category 2: defined-but-unbound) → + // start() never runs. + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + bg: + type: "{BACKGROUND_EXTENSION_URN}" + config: + probe_key: "{bg_key}" + active: + type: "{ACTIVE_EXTENSION_URN}" + config: + probe_key: "{active_key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline must shut down cleanly: {result:?}" + ); + + assert!( + bg_started.load(Ordering::SeqCst), + "Background extension survived pruning and ran its start()" + ); + assert!( + bg_shutdown_at.lock().is_some(), + "Background extension observed Shutdown" + ); + + assert!( + !active_started.load(Ordering::SeqCst), + "unbound Active extension must be dropped before runtime spawn — start() must not run" + ); + assert!( + active_start_at.lock().is_none(), + "unbound Active extension start_at must remain None" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 12 — Shared state: two receiver nodes binding the same +// passive-cloned extension share an Arc counter. +// Each consumer's `increment()` is observable to the other. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_shared_state_across_two_nodes_via_arc() { + let receiver_a = "shared-a"; + let receiver_b = "shared-b"; + let probe_a = make_probe(receiver_a, CallSequence::StatefulIncrement); + let probe_b = make_probe(receiver_b, CallSequence::StatefulIncrement); + + let ext_key = "shared-counter"; + let shared_counter: Arc = Arc::new(AtomicU64::new(0)); + register_shared_counter_probe( + ext_key, + SharedCounterProbe { + counter: Arc::clone(&shared_counter), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver_a: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_a}" + capabilities: + no_op_stateful: "shared-counter-ext" + receiver_b: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_b}" + capabilities: + no_op_stateful: "shared-counter-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + shared-counter-ext: + type: "{SHARED_COUNTER_EXTENSION_URN}" + config: + probe_key: "{ext_key}" + +connections: + - from: receiver_a + to: exporter + - from: receiver_b + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + // Both nodes claimed the capability and called `increment()`. + assert_eq!(probe_a.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!(probe_b.first_call_succeeded.load(Ordering::SeqCst), 1); + let val_a = probe_a + .stateful_increment_return + .lock() + .expect("receiver_a observed an increment return value"); + let val_b = probe_b + .stateful_increment_return + .lock() + .expect("receiver_b observed an increment return value"); + + // The two consumers were each handed a Box via .cloned() + // (clones of the prototype). The prototype's `Arc` is + // shared across clones, so the two `increment()` returns must be + // distinct values 1 and 2 (in build-order — receiver_a first, + // receiver_b second). Plus the underlying counter is now 2. + let mut returns = [val_a, val_b]; + returns.sort_unstable(); + assert_eq!( + returns, + [1, 2], + "the two nodes must observe distinct increments reflecting shared state" + ); + assert_eq!( + shared_counter.load(Ordering::SeqCst), + 2, + "underlying Arc must reflect both increments" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 13 — `.passive().constructed()` policy: the user closure runs +// ONCE PER CONSUMER, so two nodes binding to the same +// constructed extension yield two closure invocations and +// two fresh instances. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_constructed_policy_yields_fresh_instance_per_consumer() { + let receiver_a = "constructed-a"; + let receiver_b = "constructed-b"; + let probe_a = make_probe(receiver_a, CallSequence::Local); + let probe_b = make_probe(receiver_b, CallSequence::Local); + + let ext_key = "constructed-probe"; + let closure_invocations: Arc = Arc::new(AtomicUsize::new(0)); + register_constructed_probe( + ext_key, + ConstructedProbe { + closure_invocations: Arc::clone(&closure_invocations), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver_a: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_a}" + capabilities: + no_op_stateless: "constructed-ext" + receiver_b: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_b}" + capabilities: + no_op_stateless: "constructed-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + constructed-ext: + type: "{CONSTRUCTED_EXTENSION_URN}" + config: + probe_key: "{ext_key}" + +connections: + - from: receiver_a + to: exporter + - from: receiver_b + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe_a.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!(probe_b.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + closure_invocations.load(Ordering::SeqCst), + 2, + "closure must run once per consumer (2 nodes => 2 invocations)" + ); + assert_eq!( + probe_a.captured_name.lock().as_deref(), + Some("constructed-noop"), + "receiver_a sees the constructed instance" + ); + assert_eq!( + probe_b.captured_name.lock().as_deref(), + Some("constructed-noop"), + "receiver_b sees its own constructed instance" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 14 — one-shot enforcement: optional_local then require_local +// Proves that a successful `optional_local` claim consumes +// the binding's one-shot, blocking subsequent `require_*`. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_optional_local_then_require_local_errors() { + let key = "one-shot-ol-rl"; + let probe = make_probe(key, CallSequence::OptionalLocalThenRequireLocal); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "require_local after optional_local on same binding must error" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 15 — one-shot enforcement: optional_local then optional_local +// Proves that two optional_* calls on the same binding +// still trip the one-shot guard. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_optional_local_then_optional_local_errors() { + let key = "one-shot-ol-ol"; + let probe = make_probe(key, CallSequence::OptionalLocalThenOptionalLocal); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "second optional_local on same binding must error \ + (an optional accessor that finds the binding present is \ + exactly as 'consuming' as require_*)" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 16 — one-shot enforcement: optional_shared then require_shared +// Mirror of test 14, exercises the shared-side accessors. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_optional_shared_then_require_shared_errors() { + let key = "one-shot-os-rs"; + let probe = make_probe(key, CallSequence::OptionalSharedThenRequireShared); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "require_shared after optional_shared on same binding must error" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Test 17 — one-shot enforcement: optional_shared then optional_shared +// Mirror of test 15, exercises the shared-side accessors. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_one_shot_optional_shared_then_optional_shared_errors() { + let key = "one-shot-os-os"; + let probe = make_probe(key, CallSequence::OptionalSharedThenOptionalShared); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateless: "ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + ext: + type: "{PASSIVE_EXTENSION_URN}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!( + probe.second_call_already_consumed.load(Ordering::SeqCst), + 1, + "second optional_shared on same binding must error" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Lifecycle ordering — extension `start()` invoked before any node task +// ───────────────────────────────────────────────────────────────────── +// +// Asserts the framework's "extensions start first" lifecycle invariant: +// the active extension's `start()` records its entry timestamp before +// the receiver, processor, and exporter `start()` bodies record theirs. +// All three node types also consume `require_local::()` +// at build time, validating capability resolution works for every +// node type (not just receivers). + +#[test] +fn test_extension_started_before_node_tasks() { + let recv_probe_key = "lifecycle-start-recv"; + let proc_probe_key = "lifecycle-start-proc"; + let exp_probe_key = "lifecycle-start-exp"; + let lifecycle_key = "lifecycle-start"; + let ext_probe_key = "lifecycle-start-ext"; + + let recv_probe = make_probe(recv_probe_key, CallSequence::Local); + let proc_probe = make_probe(proc_probe_key, CallSequence::None); + let exp_probe = make_probe(exp_probe_key, CallSequence::None); + + register_node_lifecycle_probe(lifecycle_key, NodeLifecycleProbe::default()); + let lifecycle = lookup_node_lifecycle_probe(lifecycle_key); + + let ext_started = Arc::new(AtomicBool::new(false)); + let ext_start_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + let ext_shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_active_ext_probe( + ext_probe_key, + ActiveExtProbe { + started: Arc::clone(&ext_started), + start_at: Arc::clone(&ext_start_at), + shutdown_at: Arc::clone(&ext_shutdown_at), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{recv_probe_key}" + lifecycle_key: "{lifecycle_key}" + capabilities: + no_op_stateless: "active-ext" + processor: + type: "{PROBE_PROCESSOR_URN}" + config: + probe_key: "{proc_probe_key}" + lifecycle_key: "{lifecycle_key}" + capabilities: + no_op_stateless: "active-ext" + exporter: + type: "{PROBE_EXPORTER_URN}" + config: + probe_key: "{exp_probe_key}" + lifecycle_key: "{lifecycle_key}" + capabilities: + no_op_stateless: "active-ext" + +extensions: + active-ext: + type: "{ACTIVE_EXTENSION_URN}" + config: + probe_key: "{ext_probe_key}" + +connections: + - from: receiver + to: processor + - from: processor + to: exporter +"# + ); + + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + // Capabilities resolved at build time for all three node types. + assert_eq!( + recv_probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "receiver consumed require_local::()" + ); + assert_eq!( + proc_probe.create_calls.load(Ordering::SeqCst), + 1, + "processor create() ran" + ); + assert_eq!( + exp_probe.create_calls.load(Ordering::SeqCst), + 1, + "exporter create() ran" + ); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + + let ext_start = ext_start_at.lock().expect("extension must record start_at"); + let receiver_start = lifecycle + .receiver_start_at + .lock() + .expect("receiver must record start_at"); + let exporter_start = lifecycle + .exporter_start_at + .lock() + .expect("exporter must record start_at"); + + assert!( + ext_start <= receiver_start, + "extension start ({ext_start:?}) must precede receiver start ({receiver_start:?})" + ); + assert!( + ext_start <= exporter_start, + "extension start ({ext_start:?}) must precede exporter start ({exporter_start:?})" + ); + // The processor's `start()` is engine-internal and not exposed to + // the trait impl; we observe it via its first `process()` call, + // which can only happen after spawn. If the receiver never sends + // pdata, this slot stays None — that's fine and we skip the assert. + if let Some(processor_first) = *lifecycle.processor_first_call_at.lock() { + assert!( + ext_start <= processor_first, + "extension start ({ext_start:?}) must precede processor first \ + process() call ({processor_first:?})" + ); + } +} + +// ───────────────────────────────────────────────────────────────────── +// Lifecycle ordering — extension receives Shutdown after nodes drain +// ───────────────────────────────────────────────────────────────────── +// +// Asserts the framework's "extensions stop last" lifecycle invariant: +// the receiver and exporter record their `start()`-body exit +// timestamps, and the active extension records when it observed the +// `Shutdown` control message. The extension's shutdown timestamp must +// not precede either node's exit timestamp. + +#[test] +fn test_extension_shutdown_received_after_nodes_exit() { + let recv_probe_key = "lifecycle-stop-recv"; + let proc_probe_key = "lifecycle-stop-proc"; + let exp_probe_key = "lifecycle-stop-exp"; + let lifecycle_key = "lifecycle-stop"; + let ext_probe_key = "lifecycle-stop-ext"; + + let _recv_probe = make_probe(recv_probe_key, CallSequence::Local); + let _proc_probe = make_probe(proc_probe_key, CallSequence::None); + let _exp_probe = make_probe(exp_probe_key, CallSequence::None); + + register_node_lifecycle_probe(lifecycle_key, NodeLifecycleProbe::default()); + let lifecycle = lookup_node_lifecycle_probe(lifecycle_key); + + let ext_shutdown_at: Arc>> = + Arc::new(parking_lot::Mutex::new(None)); + register_shutdown_recording_probe( + ext_probe_key, + ShutdownRecordingProbe { + shutdown_at: Arc::clone(&ext_shutdown_at), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{recv_probe_key}" + lifecycle_key: "{lifecycle_key}" + capabilities: + no_op_stateless: "rec-ext" + processor: + type: "{PROBE_PROCESSOR_URN}" + config: + probe_key: "{proc_probe_key}" + lifecycle_key: "{lifecycle_key}" + capabilities: + no_op_stateless: "rec-ext" + exporter: + type: "{PROBE_EXPORTER_URN}" + config: + probe_key: "{exp_probe_key}" + lifecycle_key: "{lifecycle_key}" + capabilities: + no_op_stateless: "rec-ext" + +extensions: + rec-ext: + type: "{SHUTDOWN_RECORDING_EXTENSION_URN}" + config: + probe_key: "{ext_probe_key}" + +connections: + - from: receiver + to: processor + - from: processor + to: exporter +"# + ); + + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + + let ext_shutdown = ext_shutdown_at + .lock() + .expect("extension must record shutdown_at"); + let receiver_end = lifecycle + .receiver_end_at + .lock() + .expect("receiver must record end_at"); + let exporter_end = lifecycle + .exporter_end_at + .lock() + .expect("exporter must record end_at"); + + assert!( + ext_shutdown >= receiver_end, + "extension shutdown ({ext_shutdown:?}) must occur at or after \ + receiver exit ({receiver_end:?})" + ); + assert!( + ext_shutdown >= exporter_end, + "extension shutdown ({ext_shutdown:?}) must occur at or after \ + exporter exit ({exporter_end:?})" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Optional capability — `optional_local` returns Ok(None) when no +// extension provides the capability (no provider declared in YAML). +// Validates the optional path for processor and exporter consumers. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_optional_capability_returns_none_when_no_provider() { + let recv_probe_key = "optional-absent-recv"; + let proc_probe_key = "optional-absent-proc"; + let exp_probe_key = "optional-absent-exp"; + + // Receiver does not consume any capability (CallSequence::None) + // because we want a pipeline with NO extension wiring at all. + let _recv_probe = make_probe(recv_probe_key, CallSequence::None); + let proc_probe = make_probe(proc_probe_key, CallSequence::None); + let exp_probe = make_probe(exp_probe_key, CallSequence::None); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{recv_probe_key}" + processor: + type: "{PROBE_PROCESSOR_URN}" + config: + probe_key: "{proc_probe_key}" + optional_only: true + exporter: + type: "{PROBE_EXPORTER_URN}" + config: + probe_key: "{exp_probe_key}" + optional_only: true + +connections: + - from: receiver + to: processor + - from: processor + to: exporter +"# + ); + + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + // Both processor and exporter create() called optional_local with + // no provider; both must have observed Ok(None). + assert_eq!( + proc_probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "processor optional_local::() must return Ok(None) \ + when no extension provides the capability" + ); + assert_eq!( + exp_probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "exporter optional_local::() must return Ok(None) \ + when no extension provides the capability" + ); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline with no extensions should still build and run: {result:?}" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// `&mut self` (sync) on the SHARED trait variant — proves the shared +// trait's `Send` bound doesn't block sync `&mut self` invocation +// through a `Box` returned by `require_shared`. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_shared_handle_sync_mut_self_increment() { + let key = "shared-mut-sync"; + let probe = make_probe(key, CallSequence::SharedStatefulIncrement); + + let counter = Arc::new(AtomicU64::new(0)); + register_shared_counter_probe( + key, + SharedCounterProbe { + counter: Arc::clone(&counter), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateful: "shared-counter-shared" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + shared-counter-shared: + type: "{SHARED_COUNTER_SHARED_EXTENSION_URN}" + config: + probe_key: "{key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!( + probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "require_shared::() must succeed" + ); + assert_eq!( + probe.stateful_increment_return.lock().as_ref().copied(), + Some(1), + "shared handle's `&mut self` increment() must return 1 on first call" + ); + assert_eq!( + counter.load(Ordering::SeqCst), + 1, + "underlying Arc must reflect the shared-handle mutation" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// async `&mut self` on the LOCAL trait variant — proves the async +// codegen path is invokable through a `Box` retrieved at +// build time and awaited inside a node task. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_local_handle_async_mut_self_record() { + let key = "local-mut-async"; + let probe = make_probe(key, CallSequence::LocalStatefulRecordAsync); + + let counter = Arc::new(AtomicU64::new(0)); + register_shared_counter_probe( + key, + SharedCounterProbe { + counter: Arc::clone(&counter), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateful: "shared-counter" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + shared-counter: + type: "{SHARED_COUNTER_EXTENSION_URN}" + config: + probe_key: "{key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!( + probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "require_local::() must succeed at build time" + ); + // `record(7).await` runs after spawn, so the return slot is empty + // until the pipeline runs. + assert!(probe.stateful_increment_return.lock().is_none()); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + + assert_eq!( + probe.stateful_increment_return.lock().as_ref().copied(), + Some(7), + "local handle's async `&mut self` record(7) must return 7 (counter += 7)" + ); + assert_eq!( + counter.load(Ordering::SeqCst), + 7, + "underlying Arc must reflect the async record(7) mutation" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// async `&mut self` on the SHARED trait variant — proves the async + +// `Send` codegen path is invokable through a `Box` and +// awaited from inside a node task. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_shared_handle_async_mut_self_record() { + let key = "shared-mut-async"; + let probe = make_probe(key, CallSequence::SharedStatefulRecordAsync); + + let counter = Arc::new(AtomicU64::new(0)); + register_shared_counter_probe( + key, + SharedCounterProbe { + counter: Arc::clone(&counter), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{key}" + capabilities: + no_op_stateful: "shared-counter-shared" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + shared-counter-shared: + type: "{SHARED_COUNTER_SHARED_EXTENSION_URN}" + config: + probe_key: "{key}" + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!( + probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "require_shared::() must succeed at build time" + ); + assert!(probe.stateful_increment_return.lock().is_none()); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + + assert_eq!( + probe.stateful_increment_return.lock().as_ref().copied(), + Some(11), + "shared handle's async `&mut self` record(11) must return 11 (counter += 11)" + ); + assert_eq!( + counter.load(Ordering::SeqCst), + 11, + "underlying Arc must reflect the async record(11) mutation" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Cross-node shared state via `Rc>` on the LOCAL trait +// variant — proves that an `Rc`-wrapped field on a `.passive().cloned()` +// impl is observably shared across multiple consumers (clones bump +// the Rc refcount and point at the same RefCell). +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_local_shared_state_across_two_nodes_via_rc() { + let receiver_a = "rc-shared-a"; + let receiver_b = "rc-shared-b"; + let probe_a = make_probe(receiver_a, CallSequence::StatefulIncrement); + let probe_b = make_probe(receiver_b, CallSequence::StatefulIncrement); + + let yaml = format!( + r#" +nodes: + receiver_a: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_a}" + capabilities: + no_op_stateful: "rc-counter-ext" + receiver_b: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_b}" + capabilities: + no_op_stateful: "rc-counter-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + rc-counter-ext: + type: "{RC_COUNTER_EXTENSION_URN}" + +connections: + - from: receiver_a + to: exporter + - from: receiver_b + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe_a.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!(probe_b.first_call_succeeded.load(Ordering::SeqCst), 1); + let val_a = probe_a + .stateful_increment_return + .lock() + .expect("receiver_a observed an increment return value"); + let val_b = probe_b + .stateful_increment_return + .lock() + .expect("receiver_b observed an increment return value"); + + // The two consumers each got a `Clone` of the prototype. The + // prototype's `Rc>` is shared via Rc::clone, so the + // two `increment()` calls observe distinct values 1 and 2. + let mut returns = [val_a, val_b]; + returns.sort_unstable(); + assert_eq!( + returns, + [1, 2], + "Rc>-backed local impl must yield distinct increments \ + reflecting shared state across the two consumers" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Cross-node shared state via `Arc` on the SHARED trait +// variant — same idea as the existing local-Arc multi-node test, but +// goes through the `require_shared` path with the `Send` trait +// variant. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_shared_state_across_two_nodes_via_shared_arc() { + let receiver_a = "shared-arc-a"; + let receiver_b = "shared-arc-b"; + let probe_a = make_probe(receiver_a, CallSequence::SharedStatefulIncrement); + let probe_b = make_probe(receiver_b, CallSequence::SharedStatefulIncrement); + + let ext_key = "shared-arc-ext"; + let shared_counter: Arc = Arc::new(AtomicU64::new(0)); + register_shared_counter_probe( + ext_key, + SharedCounterProbe { + counter: Arc::clone(&shared_counter), + }, + ); + + let yaml = format!( + r#" +nodes: + receiver_a: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_a}" + capabilities: + no_op_stateful: "shared-arc-ext" + receiver_b: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_b}" + capabilities: + no_op_stateful: "shared-arc-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + shared-arc-ext: + type: "{SHARED_COUNTER_SHARED_EXTENSION_URN}" + config: + probe_key: "{ext_key}" + +connections: + - from: receiver_a + to: exporter + - from: receiver_b + to: exporter +"# + ); + let (_runtime_pipeline, _ctx, _key, _ts) = build_test_runtime_pipeline(&yaml); + + assert_eq!(probe_a.first_call_succeeded.load(Ordering::SeqCst), 1); + assert_eq!(probe_b.first_call_succeeded.load(Ordering::SeqCst), 1); + let val_a = probe_a + .stateful_increment_return + .lock() + .expect("receiver_a observed an increment return value"); + let val_b = probe_b + .stateful_increment_return + .lock() + .expect("receiver_b observed an increment return value"); + + let mut returns = [val_a, val_b]; + returns.sort_unstable(); + assert_eq!( + returns, + [1, 2], + "Arc-backed shared impl must yield distinct increments \ + reflecting shared state across the two consumers via require_shared" + ); + assert_eq!( + shared_counter.load(Ordering::SeqCst), + 2, + "underlying Arc must reflect both shared-handle increments" + ); +} + +// ───────────────────────────────────────────────────────────────────── +// Active extension mutates shared `Arc` state during its +// `start()` task — proves capability consumers observe pre-mutation +// state at build time and can read the post-mutation state through +// the same `Arc` after the run. The extension's impl provides BOTH +// the lifecycle and the `NoOpStateful` capability, so writes from +// `start()` and reads via the capability go through one Arc. +// ───────────────────────────────────────────────────────────────────── + +#[test] +fn test_active_extension_mutates_shared_arc_observed_by_consumers() { + let receiver_key = "active-arc-recv"; + let probe = make_probe(receiver_key, CallSequence::SharedStatefulReadCount); + + let ext_key = "active-shared-counter"; + let counter: Arc = Arc::new(AtomicU64::new(0)); + register_shared_counter_probe( + ext_key, + SharedCounterProbe { + counter: Arc::clone(&counter), + }, + ); + + let bumps: u64 = 3; + let yaml = format!( + r#" +nodes: + receiver: + type: "{PROBE_RECEIVER_URN}" + config: + probe_key: "{receiver_key}" + capabilities: + no_op_stateful: "active-counter-ext" + exporter: + type: "{NOOP_EXPORTER_URN}" + +extensions: + active-counter-ext: + type: "{ACTIVE_SHARED_COUNTER_EXTENSION_URN}" + config: + probe_key: "{ext_key}" + bumps: {bumps} + +connections: + - from: receiver + to: exporter +"# + ); + let (runtime_pipeline, ctx, entity_key, ts) = build_test_runtime_pipeline(&yaml); + + // At build time (before run_forever spawns anything), the receiver + // acquired the capability and read `count()`. The active extension + // hasn't run yet, so the value must be 0. + assert_eq!( + probe.first_call_succeeded.load(Ordering::SeqCst), + 1, + "require_shared::() must succeed at build time" + ); + assert_eq!( + probe.stateful_increment_return.lock().as_ref().copied(), + Some(0), + "build-time count() must observe pre-mutation state" + ); + assert_eq!( + counter.load(Ordering::SeqCst), + 0, + "underlying Arc must be 0 before pipeline runs" + ); + + let result = run_pipeline_with_shutdown_after( + runtime_pipeline, + ctx, + entity_key, + ts, + Duration::from_millis(150), + ); + assert!( + result.is_ok(), + "pipeline should shut down cleanly: {result:?}" + ); + + // After shutdown, the active extension's start() task has bumped + // the Arc-shared counter exactly `bumps` times. Capability consumers + // share that same Arc (via the registry-handed-out clone of the + // impl), so reads through the capability would observe the same + // value — here we verify directly through the probe's Arc handle. + assert_eq!( + counter.load(Ordering::SeqCst), + bumps, + "active extension's start() must have mutated the shared Arc \ + exactly `bumps` times, observable to capability consumers" + ); +} diff --git a/rust/otap-dataflow/crates/otap/tests/common/counting_exporter.rs b/rust/otap-dataflow/crates/otap/tests/common/counting_exporter.rs index 22d21fedaa..6155bae2a5 100644 --- a/rust/otap-dataflow/crates/otap/tests/common/counting_exporter.rs +++ b/rust/otap-dataflow/crates/otap/tests/common/counting_exporter.rs @@ -66,7 +66,8 @@ static COUNTING_EXPORTER: ExporterFactory = ExporterFactory { create: |_pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { // Look up counter by ID from node config let counter_id = node_config .config diff --git a/rust/otap-dataflow/crates/otap/tests/common/flaky_exporter.rs b/rust/otap-dataflow/crates/otap/tests/common/flaky_exporter.rs index 84d17d70eb..a82cbcf032 100644 --- a/rust/otap-dataflow/crates/otap/tests/common/flaky_exporter.rs +++ b/rust/otap-dataflow/crates/otap/tests/common/flaky_exporter.rs @@ -143,7 +143,8 @@ static FLAKY_EXPORTER: ExporterFactory = ExporterFactory { create: |_pipeline: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { // Look up state by ID from node config let flaky_id = node_config.config.get("flaky_id").and_then(|v| v.as_str()); let (counter, should_ack, nack_count, permanent_nack, permanent_nack_count) = flaky_id diff --git a/rust/otap-dataflow/crates/otap/tests/topic_pipeline_flow_tests.rs b/rust/otap-dataflow/crates/otap/tests/topic_pipeline_flow_tests.rs index b470233fc6..2201e0035a 100644 --- a/rust/otap-dataflow/crates/otap/tests/topic_pipeline_flow_tests.rs +++ b/rust/otap-dataflow/crates/otap/tests/topic_pipeline_flow_tests.rs @@ -84,6 +84,7 @@ fn topic_exporter_to_topic_receiver_transfers_pdata() { exporter_node.clone(), Arc::new(exporter_user_cfg), &ExporterConfig::new("topic_exporter"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic exporter should be created"); @@ -92,6 +93,7 @@ fn topic_exporter_to_topic_receiver_transfers_pdata() { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); @@ -231,6 +233,7 @@ fn topic_receiver_applies_source_tag_when_enabled() { exporter_node.clone(), Arc::new(exporter_user_cfg), &ExporterConfig::new("topic_exporter"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic exporter should be created"); @@ -239,6 +242,7 @@ fn topic_receiver_applies_source_tag_when_enabled() { receiver_node.clone(), Arc::new(receiver_user_cfg), &ReceiverConfig::new("topic_receiver"), + &otap_df_engine::capability::registry::Capabilities::empty(), ) .expect("topic receiver should be created"); diff --git a/rust/otap-dataflow/crates/validation/src/fanout_processor.rs b/rust/otap-dataflow/crates/validation/src/fanout_processor.rs index 9e763e0732..5c034c6058 100644 --- a/rust/otap-dataflow/crates/validation/src/fanout_processor.rs +++ b/rust/otap-dataflow/crates/validation/src/fanout_processor.rs @@ -44,7 +44,8 @@ pub static FANOUT_PROCESSOR_FACTORY: ProcessorFactory = ProcessorFact create: |pipeline_ctx: PipelineContext, node: NodeId, node_config: Arc, - processor_config: &ProcessorConfig| { + processor_config: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { let metrics = pipeline_ctx.register_metrics::(); Ok(ProcessorWrapper::local( FanoutProcessor { metrics }, diff --git a/rust/otap-dataflow/crates/validation/src/validation_exporter.rs b/rust/otap-dataflow/crates/validation/src/validation_exporter.rs index 76ddf1225b..1411c77909 100644 --- a/rust/otap-dataflow/crates/validation/src/validation_exporter.rs +++ b/rust/otap-dataflow/crates/validation/src/validation_exporter.rs @@ -104,7 +104,8 @@ pub static VALIDATION_EXPORTER_FACTORY: ExporterFactory = ExporterFac create: |pipeline_ctx: PipelineContext, node: NodeId, node_config: Arc, - exporter_config: &ExporterConfig| { + exporter_config: &ExporterConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { Ok(ExporterWrapper::local( ValidationExporter::from_config(pipeline_ctx, &node_config.config)?, node, diff --git a/rust/otap-dataflow/docs/extension-system-architecture.md b/rust/otap-dataflow/docs/extension-system-architecture.md index b0a9b9c210..6744b7b91a 100644 --- a/rust/otap-dataflow/docs/extension-system-architecture.md +++ b/rust/otap-dataflow/docs/extension-system-architecture.md @@ -23,7 +23,7 @@ is addressed in the Phase 1 implementation: | Multiple implementations of same capability | `CapabilityRegistry` keyed by `(extension_name, TypeId)` -- different extensions can provide the same capability | | Multiple configured instances | `extensions:` section in YAML, each with a unique name; nodes bind by name in `capabilities:` | | Existing config model integration | Extensions are siblings to `nodes` in the pipeline config hierarchy | -| Preserve performance model (thread-per-core) | Local extensions use `Rc` (no locks); shared extensions use `Clone + Send` with `Arc`-wrapped state | +| Preserve performance model (thread-per-core) | Local extensions use `Rc>` for shared state (no locks); shared extensions use `Clone + Send` with `Arc`-wrapped state | | Background tasks | Active extensions get their own event loop via `Extension::start()` | | Explicit capability binding | Nodes declare `capabilities: { name: extension_instance }` -- no implicit discovery | | No hot-path registry lookup | Capabilities resolved once at factory time; nodes hold typed handles for their lifetime | @@ -78,9 +78,23 @@ four additional principles: ### Ownership and cloning model -Local capabilities return `Rc` -- all -local consumers share the same instance via reference -counting. No cloning, no locks. +Local capabilities return `Box` -- each +local consumer gets an independent boxed instance, mirroring +the shared side. For a local extension to share mutable +state across consumers (e.g., a token cache, a connection +table), fields must be wrapped in `Rc>` so all +clones see the same data: + +```rust +#[derive(Clone)] +struct MyLocalExtension { + // Shared mutable state -- Rc> ensures + // clones see the same data, no Send required. + token_cache: Rc>>, + // Plain data -- cloned independently per consumer. + scope: String, +} +``` Shared capabilities return `Box` -- each consumer gets an independent clone. For shared @@ -136,10 +150,10 @@ themselves, and they never touch pipeline data directly. | | Receiver | | Exporter | | | | require | | require | | | | _local() | | _shared() | | -| | -> Rc | | -> Box | | +| | -> Box | | -> Box | | | +-----------+ +-----------+ | | | -| Local consumers get Rc | +| Local consumers get Box | | Shared consumers get Box (Send) | +----------------------------------------------------------+ ``` @@ -152,6 +166,29 @@ themselves, and they never touch pipeline data directly. nodes have drained. Passive extensions (no lifecycle) skip spawning entirely. + *Scope of the guarantee.* This orders **lifecycle + calls**, not init completion. `start()` is async, so + invoking it merely enqueues a future; the extension's + init body runs concurrently with the data path once + polling begins. Capability *construction* happens at + build time (before any spawn), so structural wiring is + always in place. However, if an extension performs + deferred async init in `start()` (opening a + connection, loading config, warming a cache), + capability consumers may observe the pre-init state + until that work completes. Today, extensions handle + this themselves -- e.g., produce final state at + capability construction time, or have the capability + surface a not-ready error/default until init has + progressed. + + *Future consideration.* If an extension genuinely + needs an init-complete guarantee before the data path + runs, the framework can later add an opt-in readiness + probe so participating extensions can block data-path + spawn until they signal ready, while non-participating + extensions keep today's behavior unchanged. + 2. **PData-free.** Extensions are completely decoupled from the pipeline data type. They use `ExtensionControlMsg` through a dedicated control channel. @@ -176,16 +213,17 @@ themselves, and they never touch pipeline data directly. `.build()`; exactly one registration is required and a second is unrepresentable in the typestate. The choice of `.shared(...)` vs `.local(...)` only governs how the - engine hosts the instance (`Send + Clone` vs `!Send`, - per-pipeline). Background extensions never appear as - the right-hand side of a capability binding; their - factory's `capabilities` field is `Option<_>::None`, - and that `None` is the engine's runtime signal "this is - a Background extension" -- capability registration is - skipped entirely. For lifecycle dispatch (event loop, - control channel, shutdown sequencing) Background is - handled exactly like Active. The shape constraints are - compile-time enforced by the typestate builder: + engine hosts the instance (`Send + Clone` vs + `Clone + !Send`, per-pipeline). Background extensions + never appear as the right-hand side of a capability + binding; their factory's `capabilities` field is + `Option<_>::None`, and that `None` is the engine's + runtime signal "this is a Background extension" -- + capability registration is skipped entirely. For + lifecycle dispatch (event loop, control channel, + shutdown sequencing) Background is handled exactly like + Active. The shape constraints are compile-time enforced + by the typestate builder: - Active and Passive **must** register >=1 capability. - Background **must** register 0 capabilities (no `extension_capabilities!` invocation). @@ -198,16 +236,13 @@ themselves, and they never touch pipeline data directly. consumers call `require_shared()` / `require_local()` and cannot observe which policy was used. - `.cloned()` -- each consumer receives a clone of - the value handed to the builder. The semantics - differ by execution model: - - For `.shared(value: E)` (`E: Clone + Send`), each - consumer gets `value.clone()` -- an independent - copy of the underlying object. - - For `.local(rc: Rc)`, each consumer gets - `Rc::clone(&rc)` -- a new handle to the *same* - underlying object. Local consumers in the same - pipeline instance therefore share one extension - instance. + the value handed to the builder. Both execution + models share the same semantics: an `E: Clone` + prototype is stored, and each consumer gets + `value.clone()` -- an independent copy of the + underlying object. To share state across consumers, + wrap fields in `Rc>` (local) or + `Arc>` (shared). - `.constructed()` -- each consumer receives a newly-constructed instance from a user-supplied `Fn() -> E + Clone` closure. @@ -227,20 +262,22 @@ themselves, and they never touch pipeline data directly. `wrap_shared_as_local` fallback. This is the most common pattern. - **Local-only:** `.active().local(Rc::new(ext)).build()` - -- only local consumers can use this extension. + -- only local consumers can use this extension + (`E: Clone` is required for the per-consumer factory). Shared consumers (`require_shared()`) get a config error. Use when the extension is inherently `!Send`. - **Dual-type:** `.active().local(Rc::new(l)).shared(s).build()` -- separate types with independent lifecycles. - **Passive cloned:** `.passive().cloned().shared(ext).build()` - -- no lifecycle; consumers clone a stored prototype. + or `.passive().cloned().local(ext).build()` -- no + lifecycle; consumers clone a stored prototype. - **Passive constructed:** `.passive().constructed().shared(|| MyExt::new(cfg.clone())).build()` -- no lifecycle; each consumer invokes the stored constructor closure. 7. **Type-safe capability resolution.** Consumers call `capabilities.require_local::()` - (returns `Rc`) or + (returns `Box`) or `capabilities.require_shared::()` (returns `Box`, which is `Send`). The zero-sized registration struct carries associated @@ -256,7 +293,7 @@ themselves, and they never touch pipeline data directly. on `require_local()`, the registered shared factory runs and its result is routed through the capability's `wrap_shared_as_local` adapter to produce - `Rc`. + `Box`. 8. **`#[capability]` proc macro.** Each capability is defined via a single `#[capability]` attribute on a From 0ac34d075b36ab1b664aa9b93c026e6ca892b5cc Mon Sep 17 00:00:00 2001 From: gouslu Date: Wed, 6 May 2026 12:31:27 -0700 Subject: [PATCH 2/7] refactor(config): unify URN format and remove NodeKind::Extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension and node URNs now share one canonical 4-segment shape `urn:::` (e.g., `urn:microsoft:extension:azure_identity_auth`), mirroring the receiver/processor/exporter convention. Short forms `:` work for both, expanding to `urn:otel::`. The previous 3-segment extension form (`urn::`) is no longer accepted. Misplacement errors include actionable hints, e.g. '(declare under `extensions:` instead of `nodes:`)'. - New private `crates/config/src/urn.rs` factors out the shared parser core (`parse_kinded_urn`, `build_canonical_urn`, segment validators, misplacement-hint formatter). - `node_urn.rs` and `extension_urn.rs` now delegate to it; their accepted-kind sets are disjoint, so `NodeUrn` can never parse an extension URN and vice versa. - Removed `NodeKind::Extension` from the node enum and all defensive match arms in `pipeline.rs`, `controller/lib.rs`, `controller/startup.rs`, `engine/lib.rs`. - Removed `Error::ExtensionInNodesSection` (became unreachable — type-rejected at parse). - Updated YAML fixtures, bundled `configs/fake-with-extension.yaml`, and all `extension_e2e.rs` test URN constants to the 4-segment form. - Added regression tests for misplacement-hint error messages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../configs/fake-with-extension.yaml | 2 +- .../crates/config/src/extension.rs | 4 +- .../crates/config/src/extension_urn.rs | 166 +++++----- rust/otap-dataflow/crates/config/src/lib.rs | 2 + rust/otap-dataflow/crates/config/src/node.rs | 5 - .../crates/config/src/node_urn.rs | 227 +++++--------- .../crates/config/src/pipeline.rs | 41 +-- rust/otap-dataflow/crates/config/src/urn.rs | 283 ++++++++++++++++++ .../crates/controller/src/lib.rs | 6 - .../crates/controller/src/startup.rs | 8 - .../engine/src/capability/registry/tests.rs | 12 +- rust/otap-dataflow/crates/engine/src/error.rs | 8 - .../crates/engine/src/extension/tests.rs | 4 +- rust/otap-dataflow/crates/engine/src/lib.rs | 10 - .../crates/engine/tests/extension_e2e.rs | 26 +- 15 files changed, 484 insertions(+), 320 deletions(-) create mode 100644 rust/otap-dataflow/crates/config/src/urn.rs diff --git a/rust/otap-dataflow/configs/fake-with-extension.yaml b/rust/otap-dataflow/configs/fake-with-extension.yaml index af2d4fb7f7..82e21b53aa 100644 --- a/rust/otap-dataflow/configs/fake-with-extension.yaml +++ b/rust/otap-dataflow/configs/fake-with-extension.yaml @@ -15,7 +15,7 @@ groups: main: extensions: sample_kv_store: - type: "urn:otap:sample_shared_key_value_store" + type: "urn:otap:extension:sample_shared_key_value_store" nodes: generator: diff --git a/rust/otap-dataflow/crates/config/src/extension.rs b/rust/otap-dataflow/crates/config/src/extension.rs index a2ab2a0d9d..426f8f9d26 100644 --- a/rust/otap-dataflow/crates/config/src/extension.rs +++ b/rust/otap-dataflow/crates/config/src/extension.rs @@ -62,7 +62,7 @@ mod tests { #[test] fn test_extension_user_config_deserialize() { let yaml = r#" -type: "urn:otap:sample_kv_store" +type: "urn:otap:extension:sample_kv_store" config: capacity: 100 "#; @@ -74,7 +74,7 @@ config: #[test] fn test_extension_user_config_rejects_capabilities() { let yaml = r#" -type: "urn:otap:auth" +type: "urn:otap:extension:auth" capabilities: some_cap: "ext" "#; diff --git a/rust/otap-dataflow/crates/config/src/extension_urn.rs b/rust/otap-dataflow/crates/config/src/extension_urn.rs index ba62afad54..2381ee2e4b 100644 --- a/rust/otap-dataflow/crates/config/src/extension_urn.rs +++ b/rust/otap-dataflow/crates/config/src/extension_urn.rs @@ -3,25 +3,38 @@ //! Extension URN parsing and validation. //! -//! Extension URNs use a simpler 3-segment format (`urn::`) -//! compared to the 4-segment node URN format (`urn:::`). -//! The `` segment is unnecessary because extensions are always -//! identified by their position in the `extensions:` config section. +//! Extension URNs follow the canonical form `urn::extension:`, +//! mirroring the `urn:::` shape used by node URNs +//! (with the kind segment fixed to the literal `extension`). The shortcut +//! `extension:` expands to `urn:otel:extension:`. +//! +//! Extensions are intentionally NOT modeled as a node kind, and +//! [`ExtensionUrn`] is a distinct type from [`crate::node_urn::NodeUrn`] +//! so the two cannot be confused. The underlying parsing primitives are +//! shared via the private [`crate::urn`] module. use crate::error::Error; +use crate::urn::{build_canonical_urn, parse_kinded_urn}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::ops::Range; +/// The single kind segment accepted by extension URNs. +const EXTENSION_KINDS: &[&str] = &["extension"]; + +/// Human-friendly URN label used in error messages. +const URN_LABEL: &str = "extension URN"; + /// Extension type URN with zero-copy access to namespace and id segments. /// -/// Format: `urn::` (e.g., `urn:otap:sample_kv_store`) +/// Canonical form: `urn::extension:` (e.g., +/// `urn:microsoft:extension:azure_identity_auth`). The kind segment is +/// fixed to the literal `extension`, mirroring the +/// `urn:::` convention used by receivers, +/// processors, and exporters. /// -/// Unlike [`NodeUrn`](crate::node_urn::NodeUrn), extension URNs have no -/// `` segment — the kind is implicit from the `extensions:` config -/// section. -/// -/// Short form `` is also accepted and expanded to `urn:otel:`. +/// Short form `extension:` is also accepted and expanded to +/// `urn:otel:extension:`. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] #[serde(try_from = "String", into = "String")] #[schemars(with = "String")] @@ -51,74 +64,15 @@ impl ExtensionUrn { } /// Parses an extension URN. - /// - /// Accepted formats: - /// - `urn::` (canonical) - /// - `` (short form, expanded to `urn:otel:`) pub fn parse(raw: &str) -> Result { - let raw = raw.trim(); - let parts: Vec<&str> = raw.split(':').collect(); - - match parts.as_slice() { - // Short form: just → urn:otel: - [id] => { - validate_segment(raw, id, "id")?; - Ok(build_extension_urn("otel", id)) - } - // Full form: urn:: - [scheme, namespace, id] => { - if !scheme.eq_ignore_ascii_case("urn") { - return Err(Error::InvalidUserConfig { - error: format!( - "Invalid extension URN `{raw}`: expected `urn::` \ - or ``" - ), - }); - } - validate_segment(raw, namespace, "namespace")?; - validate_segment(raw, id, "id")?; - let namespace = namespace.to_ascii_lowercase(); - Ok(build_extension_urn(&namespace, id)) - } - _ => Err(Error::InvalidUserConfig { - error: format!( - "Invalid extension URN `{raw}`: expected `urn::` or ``" - ), - }), - } - } -} - -fn validate_segment(raw: &str, segment: &str, label: &str) -> Result<(), Error> { - if segment.is_empty() { - return Err(Error::InvalidUserConfig { - error: format!("Invalid extension URN `{raw}`: {label} segment is empty"), - }); - } - if !segment - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') - { - return Err(Error::InvalidUserConfig { - error: format!( - "Invalid extension URN `{raw}`: {label} segment `{segment}` \ - contains invalid characters (only alphanumeric, `_`, `-` allowed)" - ), - }); - } - Ok(()) -} - -fn build_extension_urn(namespace: &str, id: &str) -> ExtensionUrn { - let raw = format!("urn:{namespace}:{id}"); - let ns_start = 4; // "urn:".len() - let ns_end = ns_start + namespace.len(); - let id_start = ns_end + 1; // ":" - let id_end = id_start + id.len(); - ExtensionUrn { - raw, - namespace_range: ns_start..ns_end, - id_range: id_start..id_end, + let parsed = parse_kinded_urn(raw, EXTENSION_KINDS, URN_LABEL)?; + let (raw, namespace_range, id_range) = + build_canonical_urn(&parsed.namespace, parsed.kind, &parsed.id); + Ok(ExtensionUrn { + raw, + namespace_range, + id_range, + }) } } @@ -134,6 +88,12 @@ impl AsRef for ExtensionUrn { } } +impl std::borrow::Borrow for ExtensionUrn { + fn borrow(&self) -> &str { + self.as_str() + } +} + impl From for String { fn from(value: ExtensionUrn) -> Self { value.raw @@ -142,8 +102,9 @@ impl From for String { impl TryFrom for ExtensionUrn { type Error = Error; + fn try_from(value: String) -> Result { - Self::parse(&value) + Self::parse(value.as_str()) } } @@ -158,31 +119,54 @@ mod tests { use super::*; #[test] - fn test_parse_full_urn() { - let urn = ExtensionUrn::parse("urn:otap:sample_kv_store").unwrap(); + fn test_parse_canonical_4_segment_urn() { + let urn = ExtensionUrn::parse("urn:otap:extension:sample_kv_store").unwrap(); assert_eq!(urn.namespace(), "otap"); assert_eq!(urn.id(), "sample_kv_store"); - assert_eq!(urn.as_str(), "urn:otap:sample_kv_store"); + assert_eq!(urn.as_str(), "urn:otap:extension:sample_kv_store"); } #[test] fn test_parse_short_form() { - let urn = ExtensionUrn::parse("my_auth").unwrap(); + let urn = ExtensionUrn::parse("extension:my_auth").unwrap(); assert_eq!(urn.namespace(), "otel"); assert_eq!(urn.id(), "my_auth"); - assert_eq!(urn.as_str(), "urn:otel:my_auth"); + assert_eq!(urn.as_str(), "urn:otel:extension:my_auth"); + } + + #[test] + fn test_parse_short_form_rejects_other_kinds() { + // The short form's kind segment must be `extension`. + assert!(ExtensionUrn::parse("receiver:my_thing").is_err()); + assert!(ExtensionUrn::parse("processor:my_thing").is_err()); + assert!(ExtensionUrn::parse("exporter:my_thing").is_err()); } #[test] - fn test_parse_case_insensitive_scheme() { - let urn = ExtensionUrn::parse("URN:Microsoft:azure_auth").unwrap(); + fn test_parse_case_insensitive_scheme_and_kind() { + let urn = ExtensionUrn::parse("URN:Microsoft:Extension:azure_auth").unwrap(); assert_eq!(urn.namespace(), "microsoft"); assert_eq!(urn.id(), "azure_auth"); } #[test] - fn test_parse_rejects_node_urn_format() { - assert!(ExtensionUrn::parse("urn:otap:extension:sample_kv_store").is_err()); + fn test_parse_rejects_3_segment_form() { + // The pre-existing 3-segment form is no longer accepted; users + // must use the 4-segment canonical form. + assert!(ExtensionUrn::parse("urn:otap:sample_kv_store").is_err()); + } + + #[test] + fn test_parse_rejects_4_segment_with_other_kind() { + // Any kind other than `extension` is rejected. + assert!(ExtensionUrn::parse("urn:otap:receiver:foo").is_err()); + assert!(ExtensionUrn::parse("urn:otap:processor:foo").is_err()); + assert!(ExtensionUrn::parse("urn:otap:exporter:foo").is_err()); + } + + #[test] + fn test_parse_rejects_bare_id() { + assert!(ExtensionUrn::parse("my_auth").is_err()); } #[test] @@ -192,20 +176,20 @@ mod tests { #[test] fn test_parse_rejects_invalid_chars() { - assert!(ExtensionUrn::parse("urn:otap:bad name").is_err()); + assert!(ExtensionUrn::parse("urn:otap:extension:bad name").is_err()); } #[test] fn test_from_static_str() { - let urn: ExtensionUrn = "urn:otap:test_ext".into(); + let urn: ExtensionUrn = "urn:otap:extension:test_ext".into(); assert_eq!(urn.id(), "test_ext"); } #[test] fn test_serde_roundtrip() { - let urn = ExtensionUrn::parse("urn:otap:my_ext").unwrap(); + let urn = ExtensionUrn::parse("urn:otap:extension:my_ext").unwrap(); let json = serde_json::to_string(&urn).unwrap(); - assert_eq!(json, "\"urn:otap:my_ext\""); + assert_eq!(json, "\"urn:otap:extension:my_ext\""); let parsed: ExtensionUrn = serde_json::from_str(&json).unwrap(); assert_eq!(parsed, urn); } diff --git a/rust/otap-dataflow/crates/config/src/lib.rs b/rust/otap-dataflow/crates/config/src/lib.rs index 55711bc009..371c1002ab 100644 --- a/rust/otap-dataflow/crates/config/src/lib.rs +++ b/rust/otap-dataflow/crates/config/src/lib.rs @@ -43,6 +43,8 @@ pub mod topic; pub mod transport_headers; /// Transport header capture and propagation policy declarations. pub mod transport_headers_policy; +/// Shared URN parsing primitives used by [`node_urn`] and [`extension_urn`]. +mod urn; pub use topic::{ SubscriptionGroupName, TopicAckPropagationMode, TopicAckPropagationPolicies, TopicBackendKind, TopicBroadcastOnLagPolicy, TopicImplSelectionPolicy, TopicName, diff --git a/rust/otap-dataflow/crates/config/src/node.rs b/rust/otap-dataflow/crates/config/src/node.rs index eaed7992d8..ea5571a743 100644 --- a/rust/otap-dataflow/crates/config/src/node.rs +++ b/rust/otap-dataflow/crates/config/src/node.rs @@ -169,8 +169,6 @@ pub enum NodeKind { Processor, /// A sink of signals Exporter, - /// A provider of shared capabilities (e.g., auth, service discovery). - Extension, // ToDo(LQ) : Add more node kinds as needed. // A connector between two pipelines @@ -185,7 +183,6 @@ impl From for Cow<'static, str> { NodeKind::Receiver => "receiver".into(), NodeKind::Processor => "processor".into(), NodeKind::Exporter => "exporter".into(), - NodeKind::Extension => "extension".into(), NodeKind::ProcessorChain => "processor_chain".into(), } } @@ -292,7 +289,6 @@ impl NodeUserConfig { NodeKind::Processor => "processor", NodeKind::Exporter => "exporter", NodeKind::ProcessorChain => "processor_chain", - NodeKind::Extension => "extension", NodeKind::Receiver => unreachable!(), } ), @@ -308,7 +304,6 @@ impl NodeUserConfig { NodeKind::Receiver => "receiver", NodeKind::Processor => "processor", NodeKind::ProcessorChain => "processor_chain", - NodeKind::Extension => "extension", NodeKind::Exporter => unreachable!(), } ), diff --git a/rust/otap-dataflow/crates/config/src/node_urn.rs b/rust/otap-dataflow/crates/config/src/node_urn.rs index 5df2c1cd6c..2724e48d0a 100644 --- a/rust/otap-dataflow/crates/config/src/node_urn.rs +++ b/rust/otap-dataflow/crates/config/src/node_urn.rs @@ -1,17 +1,31 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 -//! Plugin URN parsing and validation helpers. +//! Node URN parsing and validation. +//! +//! Node URNs follow the canonical form `urn:::` where +//! `` is one of `receiver`, `processor`, or `exporter`. The shortcut +//! `:` (no scheme/namespace) expands to `urn:otel::`. +//! +//! Extensions deliberately do NOT use this type — they have a separate +//! [`crate::extension_urn::ExtensionUrn`] type so the rest of the codebase +//! cannot accidentally treat extensions as nodes. The underlying parsing +//! primitives are shared via the private [`crate::urn`] module. use crate::error::Error; use crate::node::NodeKind; +use crate::urn::{URN_DOCS_PATH, build_canonical_urn, is_valid_segment, parse_kinded_urn}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::borrow::Cow; use std::ops::Range; -const URN_DOCS_PATH: &str = "rust/otap-dataflow/docs/urns.md"; -const EXPECTED_SEGMENT_COUNT: usize = 2; +/// Kind segments accepted by node URNs. Extensions use a disjoint set +/// in [`crate::extension_urn`]. +const NODE_KINDS: &[&str] = &["receiver", "processor", "exporter"]; + +/// Human-friendly URN label used in error messages. +const URN_LABEL: &str = "plugin urn"; /// Canonical node URN with zero-copy access to namespace and id segments. /// @@ -27,21 +41,6 @@ pub struct NodeUrn { } impl NodeUrn { - #[must_use] - pub(crate) fn from_canonical_parts( - raw: String, - namespace_range: Range, - id_range: Range, - kind: NodeKind, - ) -> Self { - Self { - raw, - namespace_range, - id_range, - kind, - } - } - /// Returns the canonical URN string (`urn:::`). #[must_use] pub fn as_str(&self) -> &str { @@ -74,35 +73,16 @@ impl NodeUrn { /// Parses and canonicalizes a node URN. pub fn parse(raw: &str) -> Result { - let raw = raw.trim(); - let parts: Vec<&str> = raw.split(':').collect(); - - match parts.as_slice() { - [_kind, _id] => { - validate_segments(raw, "otel", parts.as_slice())?; - let (kind, id) = split_segments(raw, parts.as_slice())?; - let inferred_kind = parse_kind(raw, kind)?; - Ok(build_node_urn("otel", id, kind, inferred_kind)) - } - [scheme, namespace, _kind, _id] => { - if !scheme.eq_ignore_ascii_case("urn") { - return Err(invalid_plugin_urn( - raw, - "expected `urn:::`".to_string(), - )); - } - let namespace = namespace.to_ascii_lowercase(); - let kind_id = &parts[2..]; - validate_segments(raw, &namespace, kind_id)?; - let (kind, id) = split_segments(raw, kind_id)?; - let inferred_kind = parse_kind(raw, kind)?; - Ok(build_node_urn(namespace.as_str(), id, kind, inferred_kind)) - } - _ => Err(invalid_plugin_urn( - raw, - "expected `urn:::` or `:` for otel".to_string(), - )), - } + let parsed = parse_kinded_urn(raw, NODE_KINDS, URN_LABEL)?; + let kind = node_kind_from_segment(parsed.kind); + let (raw, namespace_range, id_range) = + build_canonical_urn(&parsed.namespace, parsed.kind, &parsed.id); + Ok(NodeUrn { + raw, + namespace_range, + id_range, + kind, + }) } } @@ -169,7 +149,18 @@ impl From<&'static str> for NodeUrn { /// - shortcut form (OTel only): `:` (expanded to `urn:otel::`) pub fn validate_plugin_urn(raw: &str, expected_kind: NodeKind) -> Result { let normalized = NodeUrn::parse(raw)?; - validate_expected_kind(raw.trim(), expected_kind, normalized.kind())?; + if !kinds_match(expected_kind, normalized.kind()) { + let expected_suffix = kind_suffix(expected_kind); + let actual_suffix = kind_suffix(normalized.kind()); + return Err(Error::InvalidUserConfig { + error: format!( + "invalid {URN_LABEL} `{}`: expected kind `{expected_suffix}`, found \ + `{actual_suffix}`; expected `urn:::` or \ + `:` for otel (see {URN_DOCS_PATH})", + raw.trim() + ), + }); + } Ok(normalized) } @@ -187,10 +178,23 @@ pub fn normalize_plugin_urn_for_kind(raw: &str, expected_kind: NodeKind) -> Resu return validate_plugin_urn(raw, expected_kind); } - validate_segments(raw, "otel", &[raw])?; - let expected_suffix = kind_suffix(expected_kind); - let kind = parse_kind(raw, expected_suffix)?; - Ok(build_node_urn("otel", raw, expected_suffix, kind)) + if !is_valid_segment(raw) { + return Err(Error::InvalidUserConfig { + error: format!( + "invalid {URN_LABEL} `{raw}`: id `{raw}` must match [a-z0-9._-]; expected \ + `urn:::` or `:` for otel (see {URN_DOCS_PATH})" + ), + }); + } + + let suffix = kind_suffix(expected_kind); + let (canonical, namespace_range, id_range) = build_canonical_urn("otel", suffix, raw); + Ok(NodeUrn { + raw: canonical, + namespace_range, + id_range, + kind: node_kind_from_segment(suffix), + }) } /// Canonicalize a node type URN. @@ -208,108 +212,26 @@ const fn kind_suffix(expected_kind: NodeKind) -> &'static str { NodeKind::Receiver => "receiver", NodeKind::Processor | NodeKind::ProcessorChain => "processor", NodeKind::Exporter => "exporter", - NodeKind::Extension => "extension", - } -} - -fn validate_expected_kind(raw: &str, expected_kind: NodeKind, kind: NodeKind) -> Result<(), Error> { - let expected_suffix = kind_suffix(expected_kind); - let actual_suffix = kind_suffix(kind); - if actual_suffix != expected_suffix { - return Err(invalid_plugin_urn( - raw, - format!("expected kind `{expected_suffix}`, found `{actual_suffix}`"), - )); - } - Ok(()) -} - -fn parse_kind(raw: &str, kind: &str) -> Result { - match kind { - "receiver" => Ok(NodeKind::Receiver), - "processor" => Ok(NodeKind::Processor), - "exporter" => Ok(NodeKind::Exporter), - "extension" => Ok(NodeKind::Extension), - _ => Err(invalid_plugin_urn( - raw, - format!( - "expected kind `receiver`, `processor`, `exporter`, or `extension`, found `{kind}`" - ), - )), - } -} - -fn split_segments<'a>(raw: &str, segs: &'a [&'a str]) -> Result<(&'a str, &'a str), Error> { - if segs.len() != EXPECTED_SEGMENT_COUNT { - return Err(invalid_plugin_urn( - raw, - format!("expected exactly {EXPECTED_SEGMENT_COUNT} segments in `:`"), - )); - } - - let kind = segs[0]; - let id = segs[1]; - if kind.is_empty() || id.is_empty() { - return Err(invalid_plugin_urn( - raw, - "segments must be non-empty".to_string(), - )); - } - - Ok((kind, id)) -} - -fn validate_segments(raw: &str, namespace: &str, segs: &[&str]) -> Result<(), Error> { - if namespace.is_empty() { - return Err(invalid_plugin_urn( - raw, - "namespace must be non-empty".to_string(), - )); - } - - if segs.is_empty() || segs.iter().any(|s| s.is_empty()) { - return Err(invalid_plugin_urn( - raw, - "segments must be non-empty".to_string(), - )); - } - - if !is_valid_segment(namespace) { - return Err(invalid_plugin_urn( - raw, - format!("namespace `{namespace}` must match [a-z0-9._-]"), - )); } - - if segs.iter().any(|s| !is_valid_segment(s)) { - return Err(invalid_plugin_urn( - raw, - "segments must match [a-z0-9._-]".to_string(), - )); - } - - Ok(()) -} - -fn is_valid_segment(seg: &str) -> bool { - seg.chars() - .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.')) } -fn build_node_urn(namespace: &str, id: &str, kind_str: &str, kind: NodeKind) -> NodeUrn { - let raw = format!("urn:{namespace}:{kind_str}:{id}"); - let namespace_start = "urn:".len(); - let namespace_end = namespace_start + namespace.len(); - let id_start = namespace_end + 1 + kind_str.len() + 1; - let id_end = id_start + id.len(); - NodeUrn::from_canonical_parts(raw, namespace_start..namespace_end, id_start..id_end, kind) +/// Returns true if `expected` and `actual` correspond to the same URN +/// kind segment. Treats `Processor` and `ProcessorChain` as equivalent +/// because they both serialize to the `processor` segment. +fn kinds_match(expected: NodeKind, actual: NodeKind) -> bool { + kind_suffix(expected) == kind_suffix(actual) } -fn invalid_plugin_urn(raw: &str, details: String) -> Error { - Error::InvalidUserConfig { - error: format!( - "invalid plugin urn `{raw}`: {details}; expected `urn:::` or `:` for otel (see {URN_DOCS_PATH})" - ), +/// Map a known kind segment back to a `NodeKind`. The segment must be +/// one of [`NODE_KINDS`] (the parser guarantees this). +fn node_kind_from_segment(segment: &str) -> NodeKind { + match segment { + "receiver" => NodeKind::Receiver, + "processor" => NodeKind::Processor, + "exporter" => NodeKind::Exporter, + // Unreachable because `parse_kinded_urn` only returns a kind from + // `NODE_KINDS`; documented as a defensive panic for clarity. + other => unreachable!("node URN parser returned unexpected kind `{other}`"), } } @@ -365,6 +287,13 @@ mod tests { )); } + #[test] + fn rejects_extension_kind() { + // Extensions are not nodes; their URNs must not parse as NodeUrn. + assert!(NodeUrn::parse("urn:otap:extension:foo").is_err()); + assert!(NodeUrn::parse("extension:foo").is_err()); + } + #[test] fn rejects_mismatches_and_invalids() { // Empty NSS segments diff --git a/rust/otap-dataflow/crates/config/src/pipeline.rs b/rust/otap-dataflow/crates/config/src/pipeline.rs index 2e56d2977b..26f0472f88 100644 --- a/rust/otap-dataflow/crates/config/src/pipeline.rs +++ b/rust/otap-dataflow/crates/config/src/pipeline.rs @@ -652,8 +652,6 @@ impl PipelineConfig { !has_incoming || !has_outgoing } NodeKind::Exporter => !has_incoming, - // Extensions are in a separate section and never appear in `nodes`. - NodeKind::Extension => false, }; if should_remove { @@ -2808,7 +2806,7 @@ sink: let config = PipelineConfigBuilder::new() .add_receiver("recv", "urn:test:receiver:example", None) .add_exporter("exp", "urn:test:exporter:example", None) - .add_extension("auth", "urn:test:auth", None) + .add_extension("auth", "urn:test:extension:auth", None) .connect("recv", "", ["exp"], DispatchPolicy::Broadcast) .build(PipelineType::Otap, "g", "p") .unwrap(); @@ -2837,7 +2835,7 @@ nodes: extensions: auth: - type: "urn:test:auth" + type: "urn:test:extension:auth" config: method: "managed_identity" scope: "https://example.com/.default" @@ -2850,7 +2848,7 @@ connections: // Extension parsed with config let (_, ext_cfg) = config.extension_iter().next().unwrap(); - assert_eq!(ext_cfg.r#type.as_ref(), "urn:test:auth"); + assert_eq!(ext_cfg.r#type.as_ref(), "urn:test:extension:auth"); assert_eq!(ext_cfg.config["method"], "managed_identity"); // Exporter has capabilities binding @@ -2868,7 +2866,7 @@ connections: fn test_extension_urn_parsing() { use crate::extension::ExtensionUrn; - let urn = ExtensionUrn::parse("urn:test:auth").unwrap(); + let urn = ExtensionUrn::parse("urn:test:extension:auth").unwrap(); assert_eq!(urn.namespace(), "test"); assert_eq!(urn.id(), "auth"); } @@ -2879,7 +2877,7 @@ connections: let config = PipelineConfigBuilder::new() .add_receiver("myname", "urn:test:receiver:example", None) .add_exporter("exp", "urn:test:exporter:example", None) - .add_extension("myname", "urn:test:auth", None) + .add_extension("myname", "urn:test:extension:auth", None) .connect("myname", "", ["exp"], DispatchPolicy::Broadcast) .build(PipelineType::Otap, "g", "p") .unwrap(); @@ -2890,13 +2888,15 @@ connections: #[test] fn test_extension_urn_format_rejected_in_nodes_section() { - // Extension URNs use a 3-segment format (`urn::`) which is - // incompatible with the 4-segment node URN format (`urn:::`). - // Placing an extension-style URN under `nodes:` is a parse error. + // Both node and extension URNs share the canonical 4-segment shape + // `urn:::`, but the kind segment is disjoint: + // NodeUrn rejects `extension`, ExtensionUrn rejects + // `receiver`/`processor`/`exporter`. So an extension URN placed + // under `nodes:` is rejected at parse time. let yaml = r#" nodes: auth: - type: "urn:test:auth" + type: "urn:test:extension:auth" receiver: type: "urn:test:receiver:example" exporter: @@ -2909,14 +2909,15 @@ connections: let result = super::PipelineConfig::from_yaml("g".into(), "p".into(), yaml); assert!( result.is_err(), - "3-segment extension URN should be rejected in nodes section" + "extension URN should be rejected in nodes section" ); } #[test] fn test_receiver_urn_in_extensions_section_rejected() { - // A 4-segment node URN placed in the `extensions:` section is rejected - // at parse time because ExtensionUrn only accepts 3-segment format. + // ExtensionUrn requires the kind segment to be `extension`, so a + // node URN (kind `receiver`/`processor`/`exporter`) placed in + // the `extensions:` section is rejected at parse time. let yaml = r#" nodes: receiver: @@ -2936,7 +2937,7 @@ connections: .expect_err("should reject node URN in extensions section"); let msg = format!("{err:?}"); assert!( - msg.contains("misplaced") || msg.contains("Invalid extension URN"), + msg.contains("misplaced") || msg.contains("invalid extension URN"), "expected rejection of node URN in extensions section, got: {msg}" ); } @@ -3013,11 +3014,11 @@ nodes: extensions: auth: - type: "urn:test:auth" + type: "urn:test:extension:auth" config: method: dev kv: - type: "urn:test:kv_store" + type: "urn:test:extension:kv_store" connections: - from: receiver @@ -3086,7 +3087,7 @@ nodes: extensions: auth: - type: "urn:test:auth" + type: "urn:test:extension:auth" connections: - from: receiver @@ -3115,7 +3116,7 @@ nodes: extensions: auth: - type: "urn:test:auth" + type: "urn:test:extension:auth" capabilities: some_capability: "other_ext" @@ -3153,7 +3154,7 @@ pipelines: to: exp extensions: auth: - type: "urn:test:auth" + type: "urn:test:extension:auth" "#; let result: Result = serde_yaml::from_str(yaml); diff --git a/rust/otap-dataflow/crates/config/src/urn.rs b/rust/otap-dataflow/crates/config/src/urn.rs new file mode 100644 index 0000000000..9a35a418da --- /dev/null +++ b/rust/otap-dataflow/crates/config/src/urn.rs @@ -0,0 +1,283 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Shared URN parsing primitives. +//! +//! Both node URNs (`urn:::` where `` is one of +//! `receiver`/`processor`/`exporter`) and extension URNs +//! (`urn::extension:`) follow the same shape, so the +//! parsing/validation/canonicalization logic lives here. The kind-specific +//! wrappers in [`crate::node_urn`] and [`crate::extension_urn`] supply +//! the set of accepted kind segments and reject everything else. +//! +//! Extension and node URNs are intentionally exposed as distinct types +//! ([`crate::node_urn::NodeUrn`] vs [`crate::extension_urn::ExtensionUrn`]) +//! so the rest of the codebase cannot accidentally confuse the two; the +//! shared logic is non-public to this module and only reachable through +//! those wrappers. + +use crate::error::Error; +use std::ops::Range; + +/// Reference path used in error messages pointing to URN documentation. +pub(crate) const URN_DOCS_PATH: &str = "rust/otap-dataflow/docs/urns.md"; + +/// Successful parse result: the segments needed to build a canonical URN +/// of the form `urn:::`. The owned strings live in +/// the caller, which assembles the final canonical representation. +pub(crate) struct ParsedKindedUrn<'a> { + pub namespace: String, + pub kind: &'a str, + pub id: String, +} + +/// Parse a kinded URN, accepting both the canonical 4-segment form +/// (`urn:::`) and the short `:` form +/// (which expands to `urn:otel::`). +/// +/// `accepted_kinds` is the closed set of kind segments allowed for this +/// URN flavour. The match is case-insensitive; the canonical form +/// returned in [`ParsedKindedUrn::kind`] borrows from `accepted_kinds` +/// so the caller controls casing. +/// +/// `urn_label` is the human-friendly label used in error messages +/// (e.g., `"plugin urn"` or `"extension URN"`). +pub(crate) fn parse_kinded_urn<'a>( + raw: &str, + accepted_kinds: &'a [&'a str], + urn_label: &str, +) -> Result, Error> { + let raw = raw.trim(); + let parts: Vec<&str> = raw.split(':').collect(); + + match parts.as_slice() { + // Short form: : → urn:otel:: + [kind, id] => { + let kind = match_kind(raw, kind, accepted_kinds, urn_label)?; + validate_segment(raw, "otel", "namespace", urn_label)?; + validate_segment(raw, id, "id", urn_label)?; + Ok(ParsedKindedUrn { + namespace: "otel".to_string(), + kind, + id: (*id).to_string(), + }) + } + // Canonical form: urn::: + [scheme, namespace, kind, id] => { + if !scheme.eq_ignore_ascii_case("urn") { + return Err(invalid_urn( + raw, + format!("scheme must be `urn`, found `{scheme}`"), + urn_label, + )); + } + let namespace_lower = namespace.to_ascii_lowercase(); + let kind = match_kind(raw, kind, accepted_kinds, urn_label)?; + validate_segment(raw, &namespace_lower, "namespace", urn_label)?; + validate_segment(raw, id, "id", urn_label)?; + Ok(ParsedKindedUrn { + namespace: namespace_lower, + kind, + id: (*id).to_string(), + }) + } + _ => Err(invalid_urn( + raw, + format!("found {} segment(s)", parts.len()), + urn_label, + )), + } +} + +/// Build the canonical `urn:::` string and the byte +/// ranges of the namespace and id segments. +pub(crate) fn build_canonical_urn( + namespace: &str, + kind: &str, + id: &str, +) -> (String, Range, Range) { + let raw = format!("urn:{namespace}:{kind}:{id}"); + let namespace_start = "urn:".len(); + let namespace_end = namespace_start + namespace.len(); + let id_start = namespace_end + 1 + kind.len() + 1; + let id_end = id_start + id.len(); + (raw, namespace_start..namespace_end, id_start..id_end) +} + +/// Returns `true` if `seg` consists only of characters allowed in URN +/// segments: ASCII lowercase letters, digits, `_`, `-`, or `.`. +pub(crate) fn is_valid_segment(seg: &str) -> bool { + !seg.is_empty() + && seg + .chars() + .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.')) +} + +fn validate_segment(raw: &str, segment: &str, label: &str, urn_label: &str) -> Result<(), Error> { + if segment.is_empty() { + return Err(invalid_urn( + raw, + format!("{label} must be non-empty"), + urn_label, + )); + } + if !is_valid_segment(segment) { + return Err(invalid_urn( + raw, + format!("{label} `{segment}` must match [a-z0-9._-]"), + urn_label, + )); + } + Ok(()) +} + +fn match_kind<'a>( + raw: &str, + kind: &str, + accepted_kinds: &'a [&'a str], + urn_label: &str, +) -> Result<&'a str, Error> { + if let Some(matched) = accepted_kinds + .iter() + .copied() + .find(|expected| expected.eq_ignore_ascii_case(kind)) + { + return Ok(matched); + } + + // Kind isn't in this URN flavour's accepted set. If it's a *known* + // kind (i.e., recognized as one of the other URN flavours' kinds), + // include a hint pointing the user at the right section. This makes + // misplacement errors actionable instead of just "kind X is invalid". + let hint = misplaced_kind_hint(kind); + let expected = format_accepted_kinds(accepted_kinds); + let details = match hint { + Some(suggestion) => format!("expected kind {expected}, found `{kind}` ({suggestion})"), + None => format!("expected kind {expected}, found `{kind}`"), + }; + Err(invalid_urn(raw, details, urn_label)) +} + +/// Returns a hint string suggesting the correct config section when a +/// recognized-but-misplaced kind segment is encountered. +fn misplaced_kind_hint(kind: &str) -> Option<&'static str> { + match kind.to_ascii_lowercase().as_str() { + "receiver" | "processor" | "exporter" => { + Some("declare under `nodes:` instead of `extensions:`") + } + "extension" => Some("declare under `extensions:` instead of `nodes:`"), + _ => None, + } +} + +fn format_accepted_kinds(accepted: &[&str]) -> String { + match accepted { + [] => "".to_string(), + [single] => format!("`{single}`"), + [a, b] => format!("`{a}` or `{b}`"), + many => { + let head = many[..many.len() - 1] + .iter() + .map(|k| format!("`{k}`")) + .collect::>() + .join(", "); + let last = many[many.len() - 1]; + format!("{head}, or `{last}`") + } + } +} + +fn invalid_urn(raw: &str, details: String, urn_label: &str) -> Error { + Error::InvalidUserConfig { + error: format!( + "invalid {urn_label} `{raw}`: {details}; expected \ + `urn:::` or `:` for otel \ + (see {URN_DOCS_PATH})" + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NODE_KINDS: &[&str] = &["receiver", "processor", "exporter"]; + const EXTENSION_KINDS: &[&str] = &["extension"]; + + #[test] + fn parses_canonical_4_segment() { + let parsed = parse_kinded_urn("urn:otap:receiver:otlp", NODE_KINDS, "test urn").unwrap(); + assert_eq!(parsed.namespace, "otap"); + assert_eq!(parsed.kind, "receiver"); + assert_eq!(parsed.id, "otlp"); + } + + #[test] + fn parses_short_form() { + let parsed = parse_kinded_urn("receiver:otlp", NODE_KINDS, "test urn").unwrap(); + assert_eq!(parsed.namespace, "otel"); + assert_eq!(parsed.kind, "receiver"); + } + + #[test] + fn rejects_kind_not_in_accepted_set() { + // `extension` is not in the node kinds set. + assert!(parse_kinded_urn("urn:otap:extension:foo", NODE_KINDS, "test urn").is_err()); + // And node kinds are not in the extension set. + assert!(parse_kinded_urn("urn:otap:receiver:foo", EXTENSION_KINDS, "test urn").is_err()); + } + + #[test] + fn rejects_3_segment() { + assert!(parse_kinded_urn("urn:otap:foo", NODE_KINDS, "test urn").is_err()); + assert!(parse_kinded_urn("urn:otap:foo", EXTENSION_KINDS, "test urn").is_err()); + } + + #[test] + fn rejects_bare_id() { + assert!(parse_kinded_urn("foo", NODE_KINDS, "test urn").is_err()); + assert!(parse_kinded_urn("foo", EXTENSION_KINDS, "test urn").is_err()); + } + + #[test] + fn case_insensitive_kind_and_scheme() { + let parsed = parse_kinded_urn("URN:Otap:RECEIVER:foo", NODE_KINDS, "test urn").unwrap(); + // Returned kind has the accepted-kinds casing (lowercase). + assert_eq!(parsed.kind, "receiver"); + assert_eq!(parsed.namespace, "otap"); + } + + #[test] + fn build_round_trips() { + let (raw, ns_range, id_range) = build_canonical_urn("otap", "receiver", "otlp"); + assert_eq!(raw, "urn:otap:receiver:otlp"); + assert_eq!(&raw[ns_range], "otap"); + assert_eq!(&raw[id_range], "otlp"); + } +} + +#[cfg(test)] +mod display_tests { + use crate::extension_urn::ExtensionUrn; + use crate::node_urn::NodeUrn; + + #[test] + fn shows_misplaced_extension_under_nodes() { + let err = NodeUrn::parse("urn:otel:extension:azure_auth").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("declare under `extensions:`"), + "expected misplacement hint, got: {msg}" + ); + } + + #[test] + fn shows_misplaced_receiver_under_extensions() { + let err = ExtensionUrn::parse("urn:otel:receiver:otlp").unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("declare under `nodes:`"), + "expected misplacement hint, got: {msg}" + ); + } +} diff --git a/rust/otap-dataflow/crates/controller/src/lib.rs b/rust/otap-dataflow/crates/controller/src/lib.rs index ee635562f5..9e1ca8059f 100644 --- a/rust/otap-dataflow/crates/controller/src/lib.rs +++ b/rust/otap-dataflow/crates/controller/src/lib.rs @@ -303,11 +303,6 @@ impl { - // Extensions are not yet validated here because PipelineFactory - // does not expose an extension factory registry. - continue; - } }; let Some(validate_fn) = validate_config_fn else { @@ -315,7 +310,6 @@ impl "receiver", NodeKind::Processor | NodeKind::ProcessorChain => "processor", NodeKind::Exporter => "exporter", - NodeKind::Extension => unreachable!("handled above"), }; return Err(format!( "Unknown {} component `{}` in pipeline_group={} pipeline={} node={}", diff --git a/rust/otap-dataflow/crates/controller/src/startup.rs b/rust/otap-dataflow/crates/controller/src/startup.rs index e68825eeed..fcff83ad37 100644 --- a/rust/otap-dataflow/crates/controller/src/startup.rs +++ b/rust/otap-dataflow/crates/controller/src/startup.rs @@ -115,13 +115,6 @@ pub fn validate_pipeline_components( .get_exporter_factory_map() .get(urn_str) .map(|f| f.validate_config), - NodeKind::Extension => { - // Extensions are not yet validated here because PipelineFactory - // does not have an extension factory registry. Once one is added, - // this should look up and validate extension configs similarly to - // receivers/processors/exporters. - continue; - } }; match validate_config_fn { @@ -130,7 +123,6 @@ pub fn validate_pipeline_components( NodeKind::Receiver => "receiver", NodeKind::Processor | NodeKind::ProcessorChain => "processor", NodeKind::Exporter => "exporter", - NodeKind::Extension => unreachable!("handled above"), }; return Err(std::io::Error::other(format!( "Unknown {} component `{}` in pipeline_group={} pipeline={} node={}", diff --git a/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs b/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs index 5293ba1aab..24bbaf7fe9 100644 --- a/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs +++ b/rust/otap-dataflow/crates/engine/src/capability/registry/tests.rs @@ -785,7 +785,7 @@ fn test_end_to_end_shared_only_via_bundle() { // 1. Build a passive-cloned shared bundle around SharedImpl. let name: ExtensionId = "azure-auth".into(); let user_config = Arc::new(ExtensionUserConfig::new( - "urn:test:azure".into(), + "urn:test:extension:azure".into(), serde_json::Value::Null, )); let runtime_config = ExtensionConfig::new("azure-auth"); @@ -856,7 +856,7 @@ fn test_end_to_end_local_only_via_bundle() { let name: ExtensionId = "kv".into(); let user_config = Arc::new(ExtensionUserConfig::new( - "urn:test:kv".into(), + "urn:test:extension:kv".into(), serde_json::Value::Null, )); let runtime_config = ExtensionConfig::new("kv"); @@ -913,7 +913,7 @@ fn test_end_to_end_shared_constructed_policy_mints_independent_instances() { let name: ExtensionId = "counter".into(); let user_config = Arc::new(ExtensionUserConfig::new( - "urn:test:counter".into(), + "urn:test:extension:counter".into(), serde_json::Value::Null, )); let runtime_config = ExtensionConfig::new("counter"); @@ -1012,7 +1012,7 @@ fn test_register_into_rejects_metadata_vs_bundle_mismatch() { let name: ExtensionId = "drifty".into(); let user_config = Arc::new(ExtensionUserConfig::new( - "urn:test:drifty".into(), + "urn:test:extension:drifty".into(), serde_json::Value::Null, )); let runtime_config = ExtensionConfig::new("drifty"); @@ -1058,7 +1058,7 @@ fn test_register_into_rejects_metadata_vs_bundle_mismatch() { // Symmetric direction: shared advertised, bundle is local-only. let local_only_name: ExtensionId = "drifty-local".into(); let local_only_user_config = Arc::new(ExtensionUserConfig::new( - "urn:test:drifty-local".into(), + "urn:test:extension:drifty-local".into(), serde_json::Value::Null, )); let local_only_runtime_config = ExtensionConfig::new("drifty-local"); @@ -1352,7 +1352,7 @@ fn test_register_into_background_no_op() { let name: ExtensionId = "bg".into(); let user_config = Arc::new(ExtensionUserConfig::new( - "urn:test:bg".into(), + "urn:test:extension:bg".into(), serde_json::Value::Null, )); let runtime_config = ExtensionConfig::new("bg"); diff --git a/rust/otap-dataflow/crates/engine/src/error.rs b/rust/otap-dataflow/crates/engine/src/error.rs index b6221c9e0e..93ecc4485c 100644 --- a/rust/otap-dataflow/crates/engine/src/error.rs +++ b/rust/otap-dataflow/crates/engine/src/error.rs @@ -332,13 +332,6 @@ pub enum Error { plugin_urn: NodeUrn, }, - /// An extension was placed in the `nodes` section instead of `extensions`. - #[error("Extension `{node}` was placed in `nodes` but belongs in the `extensions` section")] - ExtensionInNodesSection { - /// The node name that was misconfigured. - node: NodeName, - }, - /// The specified extension already exists in the pipeline. #[error("The extension `{extension}` already exists")] ExtensionAlreadyExists { @@ -583,7 +576,6 @@ impl Error { Error::SpmcSharedNotSupported { .. } => "SpmcSharedNotSupported", Error::TooManyNodes {} => "TooManyNodes", Error::UnknownExporter { .. } => "UnknownExporter", - Error::ExtensionInNodesSection { .. } => "ExtensionInNodesSection", Error::ExtensionAlreadyExists { .. } => "ExtensionAlreadyExists", Error::UnknownExtension { .. } => "UnknownExtension", Error::CapabilityRegistrationFailed { .. } => "CapabilityRegistrationFailed", diff --git a/rust/otap-dataflow/crates/engine/src/extension/tests.rs b/rust/otap-dataflow/crates/engine/src/extension/tests.rs index 8b0dd6c75e..c5e1e1132a 100644 --- a/rust/otap-dataflow/crates/engine/src/extension/tests.rs +++ b/rust/otap-dataflow/crates/engine/src/extension/tests.rs @@ -32,7 +32,7 @@ fn ext_config(name: &'static str) -> (ExtensionId, Arc, Ext ( name.into(), Arc::new(ExtensionUserConfig::new( - "urn:otap:test".into(), + "urn:otap:extension:test".into(), Value::Null, )), ExtensionConfig::new(name), @@ -229,7 +229,7 @@ fn test_name_and_config_accessors() { .unwrap(); let w = set.take_shared().unwrap(); assert_eq!(w.name().as_ref(), "acc"); - assert_eq!(w.user_config().r#type.as_ref(), "urn:otap:test"); + assert_eq!(w.user_config().r#type.as_ref(), "urn:otap:extension:test"); } #[test] diff --git a/rust/otap-dataflow/crates/engine/src/lib.rs b/rust/otap-dataflow/crates/engine/src/lib.rs index f18541894b..5fbd67389a 100644 --- a/rust/otap-dataflow/crates/engine/src/lib.rs +++ b/rust/otap-dataflow/crates/engine/src/lib.rs @@ -755,9 +755,6 @@ impl PipelineFactory { kind: "ProcessorChain".into(), }); } - otap_df_config::node::NodeKind::Extension => { - return Err(Error::ExtensionInNodesSection { node: name.clone() }); - } }; let node_id = build_state.next_node_id(name.clone(), node_type, pipe_node)?; let _ = node_ids.insert(name.clone(), node_id); @@ -940,9 +937,6 @@ impl PipelineFactory { // ToDo(LQ): Implement processor chain optimization to eliminate intermediary channels. unreachable!("rejected in first pass"); } - otap_df_config::node::NodeKind::Extension => { - return Err(Error::ExtensionInNodesSection { node: name.clone() }); - } } } @@ -1161,10 +1155,6 @@ impl PipelineFactory { kind: "ProcessorChain".into(), }); } - otap_df_config::node::NodeKind::Extension => { - // Extensions don't participate in wiring contracts. - continue; - } }; _ = contracts_by_node.insert(node_name.as_ref().to_string().into(), contract); diff --git a/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs b/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs index f8d8e23a06..1287786f7f 100644 --- a/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs +++ b/rust/otap-dataflow/crates/engine/tests/extension_e2e.rs @@ -209,11 +209,11 @@ const PROBE_RECEIVER_URN: &str = "urn:test:receiver:probe"; const PROBE_PROCESSOR_URN: &str = "urn:test:processor:probe"; const PROBE_EXPORTER_URN: &str = "urn:test:exporter:probe"; const NOOP_EXPORTER_URN: &str = "urn:test:exporter:noop"; -const PASSIVE_EXTENSION_URN: &str = "urn:test:passive_extension"; -const DUAL_EXTENSION_URN: &str = "urn:test:dual_extension"; -const ACTIVE_EXTENSION_URN: &str = "urn:test:active_extension"; -const FAILING_EXTENSION_URN: &str = "urn:test:failing_extension"; -const SHUTDOWN_RECORDING_EXTENSION_URN: &str = "urn:test:shutdown_recording_extension"; +const PASSIVE_EXTENSION_URN: &str = "urn:test:extension:passive_extension"; +const DUAL_EXTENSION_URN: &str = "urn:test:extension:dual_extension"; +const ACTIVE_EXTENSION_URN: &str = "urn:test:extension:active_extension"; +const FAILING_EXTENSION_URN: &str = "urn:test:extension:failing_extension"; +const SHUTDOWN_RECORDING_EXTENSION_URN: &str = "urn:test:extension:shutdown_recording_extension"; // ───────────────────────────────────────────────────────────────────── // Probe receiver — exercises Capabilities API in create() @@ -793,7 +793,8 @@ impl otap_df_engine::shared::extension::Extension for ActiveSharedCounterImpl { } } -const ACTIVE_SHARED_COUNTER_EXTENSION_URN: &str = "urn:test:active_shared_counter_extension"; +const ACTIVE_SHARED_COUNTER_EXTENSION_URN: &str = + "urn:test:extension:active_shared_counter_extension"; fn active_shared_counter_extension_create( _pipeline_ctx: PipelineContext, @@ -1063,7 +1064,7 @@ impl otap_df_engine::local::extension::Extension for ActiveLocalExtImpl { } } -const DUAL_ACTIVE_EXTENSION_URN: &str = "urn:test:dual_active_extension"; +const DUAL_ACTIVE_EXTENSION_URN: &str = "urn:test:extension:dual_active_extension"; fn dual_active_extension_create( _pipeline_ctx: PipelineContext, @@ -1146,7 +1147,7 @@ impl otap_df_engine::shared::extension::Extension for BackgroundExtImpl { } } -const BACKGROUND_EXTENSION_URN: &str = "urn:test:background_extension"; +const BACKGROUND_EXTENSION_URN: &str = "urn:test:extension:background_extension"; #[derive(Clone)] struct BackgroundProbe { @@ -1267,7 +1268,7 @@ impl LocalNoOpStateful for SharedCounterImpl { } } -const SHARED_COUNTER_EXTENSION_URN: &str = "urn:test:shared_counter_extension"; +const SHARED_COUNTER_EXTENSION_URN: &str = "urn:test:extension:shared_counter_extension"; #[derive(Clone)] struct SharedCounterProbe { @@ -1345,7 +1346,8 @@ const SHARED_COUNTER_EXTENSION_FACTORY: ExtensionFactory = ExtensionFactory { // `&mut self` on the `Send` shared trait variant). // ───────────────────────────────────────────────────────────────────── -const SHARED_COUNTER_SHARED_EXTENSION_URN: &str = "urn:test:shared_counter_shared_extension"; +const SHARED_COUNTER_SHARED_EXTENSION_URN: &str = + "urn:test:extension:shared_counter_shared_extension"; fn shared_counter_shared_extension_create( _pipeline_ctx: PipelineContext, @@ -1408,7 +1410,7 @@ impl LocalNoOpStateless for ConstructedNoOpImpl { } } -const CONSTRUCTED_EXTENSION_URN: &str = "urn:test:constructed_extension"; +const CONSTRUCTED_EXTENSION_URN: &str = "urn:test:extension:constructed_extension"; #[derive(Clone)] struct ConstructedProbe { @@ -1516,7 +1518,7 @@ impl LocalNoOpStateful for RcCounterImpl { } } -const RC_COUNTER_EXTENSION_URN: &str = "urn:test:rc_counter_extension"; +const RC_COUNTER_EXTENSION_URN: &str = "urn:test:extension:rc_counter_extension"; fn rc_counter_extension_create( _pipeline_ctx: PipelineContext, From 37d92224afe6d2b9027909441708e85c862a93f1 Mon Sep 17 00:00:00 2001 From: gouslu Date: Wed, 6 May 2026 12:58:03 -0700 Subject: [PATCH 3/7] docs(macros): tighten capability receiver-shape docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../crates/engine-macros/src/capability.rs | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/rust/otap-dataflow/crates/engine-macros/src/capability.rs b/rust/otap-dataflow/crates/engine-macros/src/capability.rs index fda5424de6..ddd764886f 100644 --- a/rust/otap-dataflow/crates/engine-macros/src/capability.rs +++ b/rust/otap-dataflow/crates/engine-macros/src/capability.rs @@ -50,17 +50,13 @@ //! implementations could have different associated types, making a single //! registry entry impossible. //! - **Associated constants** — same fundamental issue as associated types. -//! -//! # Receiver shapes (no macro-side validation) -//! -//! `&self` and `&mut self` are the supported receiver shapes for -//! `#[capability]` methods. Other shapes (consuming `self`, arbitrary -//! self types like `self: Box`, methods with no `self` -//! receiver) are not validated by the macro — Rust's object-safety -//! checks at the use sites of `dyn local::Trait` / `dyn shared::Trait`, -//! and the generated `SharedAsLocal` adapter, will reject anything -//! the system cannot support, with clearer error messages than the -//! macro could synthesize. +//! - **Receiver shapes** — only `&self` and `&mut self` are supported. +//! Other shapes (consuming `self`, arbitrary self types like +//! `self: Box`, no-`self` associated functions) make the +//! trait either non-object-safe or non-dispatchable through `dyn`, +//! so the generated `Box` field on +//! `SharedAsLocal` (or its delegating method bodies) will fail to +//! compile right at the capability definition. //! //! # Generated code paths //! @@ -178,12 +174,13 @@ fn validate_trait(trait_item: &ItemTrait) -> Result<(), TokenStream> { .to_compile_error()); } TraitItem::Fn(_) => { - // No macro-side receiver validation. Object-safety - // and adapter delegation are enforced by the Rust - // compiler at the use sites of `dyn local::Trait` / - // `dyn shared::Trait` and inside the generated - // `SharedAsLocal` adapter, which gives clearer - // error messages than what the macro could emit. + // No macro-side receiver validation. Any unsupported + // receiver shape (consuming `self`, arbitrary self + // types, no-`self` associated functions) will be + // rejected by the compiler when the macro-generated + // `Box` field on `SharedAsLocal` + // (or its delegating method bodies) is type-checked, + // with clearer errors than the macro could synthesize. } _ => {} } From 7490ae4744e33cb82345f080bf67cfaf6f460f57 Mon Sep 17 00:00:00 2001 From: gouslu Date: Wed, 6 May 2026 13:26:55 -0700 Subject: [PATCH 4/7] refactor(engine): extract ExtensionLifecycle from runtime_pipeline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../crates/engine/src/extension_lifecycle.rs | 153 ++++++++++++++++++ rust/otap-dataflow/crates/engine/src/lib.rs | 1 + .../crates/engine/src/runtime_pipeline.rs | 85 +++------- 3 files changed, 176 insertions(+), 63 deletions(-) create mode 100644 rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs diff --git a/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs b/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs new file mode 100644 index 0000000000..011637813b --- /dev/null +++ b/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs @@ -0,0 +1,153 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +//! Extension lifecycle holder for the runtime pipeline. +//! +//! Owns the spawned active+background extension tasks, the control +//! senders used to broadcast `Shutdown` to them, and the passive +//! extension wrappers that must outlive the run for capability +//! handles to remain valid. Encapsulates the "extensions start +//! first, shut down last" invariant so the runtime pipeline doesn't +//! interleave that policy with task-driving code. +//! +//! ## Shutdown timing +//! +//! Extensions shut down strictly after all data-path tasks (nodes +//! and the dispatcher) have terminated. Because shutdown is +//! sequential — not simultaneous with the data path — the +//! extension shutdown deadline is computed locally as +//! `now() + EXTENSION_SHUTDOWN_GRACE` rather than reusing the +//! pipeline-wide deadline that drove the data-path drain. This +//! gives extensions a fresh cleanup budget starting from the +//! moment the data path is fully drained. +//! +//! See `runtime_pipeline.rs::run_forever` for how this is wired in. + +use crate::control::{ExtensionControlMsg, ExtensionControlSender}; +use crate::error::Error; +use crate::extension::ExtensionWrapper; +use futures::stream::{FuturesUnordered, StreamExt}; +use otap_df_telemetry::otel_warn; +use otap_df_telemetry::reporter::MetricsReporter; +use std::time::{Duration, Instant}; +use tokio::task::{JoinError, JoinHandle, LocalSet}; + +/// Cleanup window granted to extensions after the data path has +/// drained. Extensions that don't terminate within this window will +/// be left to the runtime's natural drop semantics when +/// `run_forever` returns. +pub(crate) const EXTENSION_SHUTDOWN_GRACE: Duration = Duration::from_secs(5); + +const SHUTDOWN_REASON: &str = "pipeline data-path drained"; + +/// Holds the spawned extension tasks, control senders, and passive +/// wrappers for the duration of a pipeline run. +pub(crate) struct ExtensionLifecycle { + /// Active+background extension `JoinHandle`s, awaited concurrently + /// with the data path. + futures: FuturesUnordered>>, + /// Control senders for the extensions in [`Self::futures`], used + /// once to broadcast `Shutdown` after the data path drains. + shutdown_senders: Vec, + /// Passive extensions held alive for the duration of the run so + /// any state their capability instances reference (via cloned + /// `Arc`s minted by the builder) survives until `run_forever` + /// returns and this struct is dropped. + _passive: Vec, + /// One-shot latch: `true` after `Shutdown` has been broadcast. + /// Prevents re-firing on subsequent loop iterations. + shutdown_broadcast_fired: bool, +} + +impl ExtensionLifecycle { + /// Spawn all active+background extensions onto `local_tasks` and + /// stash the passive ones. Active+background extensions begin + /// running concurrently with the data path; passive extensions + /// have no lifecycle but must remain owned for their capability + /// state to remain valid. + pub fn spawn( + extensions: Vec, + local_tasks: &LocalSet, + metrics_reporter: MetricsReporter, + ) -> Self { + let futures = FuturesUnordered::new(); + let mut shutdown_senders = Vec::new(); + let mut passive = Vec::new(); + + for ext_wrapper in extensions { + if ext_wrapper.is_passive() { + passive.push(ext_wrapper); + continue; + } + if let Some(sender) = ext_wrapper.extension_control_sender() { + shutdown_senders.push(sender); + } + let ext_metrics_reporter = metrics_reporter.clone(); + let ext_id = ext_wrapper.name(); + let fut = async move { + match ext_wrapper.start(ext_metrics_reporter).await { + Ok(_terminal_state) => Ok(()), + Err(e) => { + otel_warn!( + "extension.task.error", + extension = ext_id.as_ref(), + error = format!("{e}"), + ); + Err(e) + } + } + }; + futures.push(local_tasks.spawn_local(fut)); + } + + Self { + futures, + shutdown_senders, + _passive: passive, + shutdown_broadcast_fired: false, + } + } + + /// Returns `true` if there are no remaining active+background + /// extension tasks to await. + pub fn is_empty(&self) -> bool { + self.futures.is_empty() + } + + /// Awaits the next active+background extension task to complete. + /// Returns `None` when no extension tasks remain. + pub async fn next_completion( + &mut self, + ) -> Option, JoinError>> { + self.futures.next().await + } + + /// Broadcasts `Shutdown` to all active+background extensions. + /// Idempotent — subsequent calls are no-ops. + /// + /// The deadline is computed locally as + /// `now() + EXTENSION_SHUTDOWN_GRACE`. Extensions are expected + /// to be invoked only after every data-path task has terminated, + /// so this is the start of the extension cleanup window — not a + /// continuation of the pipeline-wide deadline. + pub fn broadcast_shutdown(&mut self) { + if self.shutdown_broadcast_fired || self.shutdown_senders.is_empty() { + return; + } + self.shutdown_broadcast_fired = true; + + let deadline = Instant::now() + EXTENSION_SHUTDOWN_GRACE; + for sender in &self.shutdown_senders { + // `try_send` is intentional: the extension's control + // channel is a small mpsc and we don't want shutdown + // broadcast to block the runtime loop. Drop on full is + // acceptable — the channel's only other writer is the + // dispatcher, which has already terminated by the time + // this is called. + let _ = sender.sender.try_send(ExtensionControlMsg::Shutdown { + deadline, + reason: SHUTDOWN_REASON.to_string(), + }); + } + } +} diff --git a/rust/otap-dataflow/crates/engine/src/lib.rs b/rust/otap-dataflow/crates/engine/src/lib.rs index 83e35a2c6d..4bebe5cec9 100644 --- a/rust/otap-dataflow/crates/engine/src/lib.rs +++ b/rust/otap-dataflow/crates/engine/src/lib.rs @@ -57,6 +57,7 @@ pub mod clock; pub mod error; pub mod exporter; pub mod extension; +mod extension_lifecycle; pub mod message; pub mod processor; pub mod receiver; diff --git a/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs b/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs index d9a6937f41..1b3c0cd5bd 100644 --- a/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs +++ b/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs @@ -229,20 +229,6 @@ impl>> = - FuturesUnordered::new(); - let mut extension_shutdown_senders: Vec = - Vec::new(); - // Passive extensions hold the engine-side state that capability - // consumers' instances may reference (via cloned `Arc`s minted by - // the builder). Keeping the wrapper alive for the duration of the - // pipeline run prevents that state from being dropped while - // consumers still hold handles. When `run_forever` returns, these - // wrappers drop and any remaining shared state is released. - let mut _passive_extensions: Vec = Vec::new(); // Lifecycle invariant: "extensions start first, shut down last". // Concretely, `start()` is invoked on every active extension before @@ -270,31 +256,12 @@ impl Ok(()), - Err(e) => { - otap_df_telemetry::otel_warn!( - "extension.task.error", - extension = ext_id.as_ref(), - error = format!("{e}"), - ); - Err(e) - } - } - }; - extension_futures.push(local_tasks.spawn_local(fut)); - } + let mut extension_lifecycle = crate::extension_lifecycle::ExtensionLifecycle::spawn( + extensions, + &local_tasks, + metrics_reporter.clone(), + ); + let mut control_senders = ControlSenders::default(); let mut node_metric_entries: Vec<(usize, NodeMetricHandles)> = Vec::new(); @@ -568,15 +535,15 @@ impl { + Some(result) = extension_lifecycle.next_completion(), if !extension_lifecycle.is_empty() => { match result { Ok(Ok(())) => {} Ok(Err(e)) => return Err(e), @@ -607,24 +574,16 @@ impl Date: Wed, 6 May 2026 14:47:20 -0700 Subject: [PATCH 5/7] Fix recordset_kql tests; clarify supervisor select intent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/processors/recordset_kql_processor/mod.rs | 1 + .../processors/recordset_kql_processor/processor.rs | 12 +++++++++--- .../crates/engine/src/extension_lifecycle.rs | 4 +--- .../crates/engine/src/runtime_pipeline.rs | 3 +++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/mod.rs b/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/mod.rs index af101e50a8..ce86699250 100644 --- a/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/mod.rs +++ b/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/mod.rs @@ -20,6 +20,7 @@ pub fn create_recordset_kql_processor( node: NodeId, node_config: Arc, processor_config: &ProcessorConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities, ) -> Result, ConfigError> { let config: RecordsetKqlProcessorConfig = serde_json::from_value(node_config.config.clone()) .map_err(|e| ConfigError::InvalidUserConfig { diff --git a/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/processor.rs b/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/processor.rs index 1dffa38603..029f325919 100644 --- a/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/processor.rs +++ b/rust/otap-dataflow/crates/contrib-nodes/src/processors/recordset_kql_processor/processor.rs @@ -295,6 +295,7 @@ mod tests { use super::*; use bytes::BytesMut; use otap_df_config::node::NodeUserConfig; + use otap_df_engine::capability; use otap_df_engine::context::ControllerContext; use otap_df_engine::message::Message; use otap_df_engine::testing::{node::test_node, processor::TestRuntime}; @@ -371,9 +372,14 @@ mod tests { json!({ "query": query }) }; - let proc = - create_recordset_kql_processor(pipeline_ctx, node, Arc::new(node_config), rt.config()) - .expect("create processor"); + let proc = create_recordset_kql_processor( + pipeline_ctx, + node, + Arc::new(node_config), + rt.config(), + &capability::registry::Capabilities::empty(), + ) + .expect("create processor"); let phase = rt.set_processor(proc); phase diff --git a/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs b/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs index 011637813b..f37e25cad6 100644 --- a/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs +++ b/rust/otap-dataflow/crates/engine/src/extension_lifecycle.rs @@ -116,9 +116,7 @@ impl ExtensionLifecycle { /// Awaits the next active+background extension task to complete. /// Returns `None` when no extension tasks remain. - pub async fn next_completion( - &mut self, - ) -> Option, JoinError>> { + pub async fn next_completion(&mut self) -> Option, JoinError>> { self.futures.next().await } diff --git a/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs b/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs index 1b3c0cd5bd..bb93d9ab47 100644 --- a/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs +++ b/rust/otap-dataflow/crates/engine/src/runtime_pipeline.rs @@ -546,6 +546,9 @@ impl { From c84f138c5195e2e494ef70228c05cc29dfe3eaf8 Mon Sep 17 00:00:00 2001 From: gouslu Date: Fri, 8 May 2026 10:12:59 -0700 Subject: [PATCH 6/7] Fix host_metrics_receiver factory signature Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core-nodes/src/receivers/host_metrics_receiver/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/rust/otap-dataflow/crates/core-nodes/src/receivers/host_metrics_receiver/mod.rs b/rust/otap-dataflow/crates/core-nodes/src/receivers/host_metrics_receiver/mod.rs index e59c025cf6..39eb6bf095 100644 --- a/rust/otap-dataflow/crates/core-nodes/src/receivers/host_metrics_receiver/mod.rs +++ b/rust/otap-dataflow/crates/core-nodes/src/receivers/host_metrics_receiver/mod.rs @@ -128,7 +128,13 @@ pub struct HostMetricsReceiver { /// Declares the host metrics receiver as a local receiver factory. pub static HOST_METRICS_RECEIVER: ReceiverFactory = ReceiverFactory { name: HOST_METRICS_RECEIVER_URN, - create: create_host_metrics_receiver, + create: |pipeline: PipelineContext, + node: NodeId, + node_config: Arc, + receiver_config: &ReceiverConfig, + _capabilities: &otap_df_engine::capability::registry::Capabilities| { + create_host_metrics_receiver(pipeline, node, node_config, receiver_config) + }, wiring_contract: otap_df_engine::wiring_contract::WiringContract::UNRESTRICTED, validate_config: validate_host_metrics_config, }; From 916d24e4e29c31b65b3cdd6577ec018b77b78248 Mon Sep 17 00:00:00 2001 From: gouslu Date: Fri, 8 May 2026 10:28:50 -0700 Subject: [PATCH 7/7] fix(comparison_dashboard): rename otap-otap.yaml to .j2 to fix config validation The file contains Jinja placeholders ({{core_start}}, {{core_end}}) but was missing the .j2 suffix, so the validate-configs CI script picked it up as a real config and failed YAML parsing. Sibling otlp-otlp.yaml.j2 and otlphttp-otlphttp.yaml.j2 already use the .j2 convention; align otap-otap with them and update the three suite references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../suites/dfe/dfe-logs-otap-gzip-baseline.yaml | 2 +- .../suites/dfe/dfe-logs-otap-none-baseline.yaml | 2 +- .../suites/dfe/dfe-logs-otap-zstd-baseline.yaml | 2 +- .../templates/engine/{otap-otap.yaml => otap-otap.yaml.j2} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename tools/pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/{otap-otap.yaml => otap-otap.yaml.j2} (100%) diff --git a/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-gzip-baseline.yaml b/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-gzip-baseline.yaml index 7f66e6017d..28ab01cae5 100644 --- a/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-gzip-baseline.yaml +++ b/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-gzip-baseline.yaml @@ -11,7 +11,7 @@ meta: variables: report: ../pipeline_perf_test/test_suites/comparison_dashboard/reports/report_logs.yaml - engine_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml + engine_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml.j2 loadgen_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/loadgen/otap.yaml.j2 backend_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/backend/otap.yaml.j2 protocol: otap diff --git a/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-none-baseline.yaml b/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-none-baseline.yaml index e91dbdbe97..23bbbd87b5 100644 --- a/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-none-baseline.yaml +++ b/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-none-baseline.yaml @@ -11,7 +11,7 @@ meta: variables: report: ../pipeline_perf_test/test_suites/comparison_dashboard/reports/report_logs.yaml - engine_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml + engine_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml.j2 loadgen_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/loadgen/otap.yaml.j2 backend_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/backend/otap.yaml.j2 protocol: otap diff --git a/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-zstd-baseline.yaml b/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-zstd-baseline.yaml index 416d681158..75913a959f 100644 --- a/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-zstd-baseline.yaml +++ b/tools/comparison_dashboard/suites/dfe/dfe-logs-otap-zstd-baseline.yaml @@ -11,7 +11,7 @@ meta: variables: report: ../pipeline_perf_test/test_suites/comparison_dashboard/reports/report_logs.yaml - engine_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml + engine_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml.j2 loadgen_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/loadgen/otap.yaml.j2 backend_config: ../pipeline_perf_test/test_suites/comparison_dashboard/templates/backend/otap.yaml.j2 protocol: otap diff --git a/tools/pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml b/tools/pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml.j2 similarity index 100% rename from tools/pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml rename to tools/pipeline_perf_test/test_suites/comparison_dashboard/templates/engine/otap-otap.yaml.j2