-
Notifications
You must be signed in to change notification settings - Fork 141
[connectors] Add preprocessor API with ConnectorMetadata #6580
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ | |
| //! Transport → Preprocessor → Parser → Circuit | ||
| //! ``` | ||
|
|
||
| use crate::ConnectorMetadata; | ||
| use crate::format::{ParseError, Splitter}; | ||
| use feldera_types::preprocess::PreprocessorConfig; | ||
| use std::collections::BTreeMap; | ||
|
|
@@ -47,12 +48,39 @@ impl std::error::Error for PreprocessorCreateError {} | |
| pub trait Preprocessor: Send + Sync { | ||
| /// Process raw input data and return transformed data. | ||
| /// | ||
| /// The default implementation forwards to `process_with_metadata` with no | ||
| /// metadata. | ||
| /// | ||
| /// # Arguments | ||
| /// * `data` - Raw input data bytes | ||
| /// | ||
| /// # Returns | ||
| /// A `PreprocessResult` containing the transformed data or errors. | ||
| fn process(&mut self, data: &[u8]) -> (Vec<u8>, Vec<ParseError>); | ||
| /// The transformed data and any errors that occurred. | ||
| fn process(&mut self, data: &[u8]) -> (Vec<u8>, Vec<ParseError>) { | ||
| self.process_with_metadata(data, None) | ||
| } | ||
|
|
||
| /// Process raw input data and return transformed data using connector metadata. | ||
| /// | ||
| /// Users should implement exactly one of the two processing methods: `process` or | ||
| /// `process_with_metadata`. | ||
| /// | ||
| /// WARNING: a preprocessor that implements neither processing method | ||
| /// compiles, but will panic at runtime with a stack overflow. | ||
| /// | ||
| /// # Arguments | ||
| /// * `data` - Raw input data bytes | ||
| /// * `_metadata` - Connector metadata | ||
| /// | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit (carried from prior review): the Arguments doc still names the param |
||
| /// # Returns | ||
| /// The transformed data and any errors that occurred. | ||
| fn process_with_metadata( | ||
| &mut self, | ||
| data: &[u8], | ||
| _metadata: Option<&ConnectorMetadata>, | ||
| ) -> (Vec<u8>, Vec<ParseError>) { | ||
| self.process(data) | ||
| } | ||
|
|
||
| /// Create a new preprocessor with the same configuration as `self`. | ||
| /// | ||
|
|
@@ -101,3 +129,129 @@ impl PreprocessorRegistry { | |
| self.registered.get(name).cloned() | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use feldera_sqllib::{SqlString, Variant}; | ||
| use serde_json::json; | ||
|
|
||
| /// Preprocessor whose output depends on connector metadata: it prepends | ||
| /// the value of the `topic` attribute to each record. Reads the attribute | ||
| /// through `ConnectorMetadata::get_by_name`, the same path a user-defined | ||
| /// preprocessor must use. | ||
| struct TopicPrefixPreprocessor; | ||
|
|
||
| impl Preprocessor for TopicPrefixPreprocessor { | ||
| fn process_with_metadata( | ||
| &mut self, | ||
| data: &[u8], | ||
| metadata: Option<&ConnectorMetadata>, | ||
| ) -> (Vec<u8>, Vec<ParseError>) { | ||
| let Some(metadata) = metadata else { | ||
| // No metadata attached: pass the record through unchanged. | ||
| return (data.to_vec(), vec![]); | ||
| }; | ||
| let mut output = Vec::new(); | ||
| if let Some(Variant::String(topic)) = metadata.get_by_name("topic") { | ||
| output.extend_from_slice(topic.str().as_bytes()); | ||
| output.push(b':'); | ||
| } | ||
| output.extend_from_slice(data); | ||
| (output, vec![]) | ||
| } | ||
|
|
||
| fn fork(&self) -> Box<dyn Preprocessor> { | ||
| Box::new(TopicPrefixPreprocessor) | ||
| } | ||
|
|
||
| fn splitter(&self) -> Option<Box<dyn Splitter>> { | ||
| None | ||
| } | ||
| } | ||
|
|
||
| /// Preprocessor with an observable transformation, for registry tests. | ||
| struct UppercasePreprocessor; | ||
|
|
||
| impl Preprocessor for UppercasePreprocessor { | ||
| fn process(&mut self, data: &[u8]) -> (Vec<u8>, Vec<ParseError>) { | ||
| (data.to_ascii_uppercase(), vec![]) | ||
| } | ||
|
|
||
| fn fork(&self) -> Box<dyn Preprocessor> { | ||
| Box::new(UppercasePreprocessor) | ||
| } | ||
|
|
||
| fn splitter(&self) -> Option<Box<dyn Splitter>> { | ||
| None | ||
| } | ||
| } | ||
|
|
||
| struct UppercasePreprocessorFactory; | ||
|
|
||
| impl PreprocessorFactory for UppercasePreprocessorFactory { | ||
| fn create( | ||
| &self, | ||
| _config: &PreprocessorConfig, | ||
| ) -> Result<Box<dyn Preprocessor>, PreprocessorCreateError> { | ||
| Ok(Box::new(UppercasePreprocessor)) | ||
| } | ||
| } | ||
|
|
||
| fn make_config(name: &str) -> PreprocessorConfig { | ||
| PreprocessorConfig { | ||
| name: name.to_string(), | ||
| message_oriented: false, | ||
| config: json!({}), | ||
| } | ||
| } | ||
|
|
||
| fn make_metadata() -> ConnectorMetadata { | ||
| let mut metadata = ConnectorMetadata::new(); | ||
| metadata.insert("topic", Variant::String(SqlString::from("events"))); | ||
| metadata | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_process_metadata_transforms_using_metadata() { | ||
| let mut preprocessor = TopicPrefixPreprocessor; | ||
|
|
||
| // `make_metadata` sets the `topic` attribute to `events`. | ||
| let metadata = make_metadata(); | ||
| let (output, errors) = preprocessor.process_with_metadata(b"payload", Some(&metadata)); | ||
| assert!(errors.is_empty(), "unexpected errors: {errors:?}"); | ||
| assert_eq!(output, b"events:payload"); | ||
|
|
||
| // Without metadata the record passes through unchanged. | ||
| let (output, errors) = preprocessor.process_with_metadata(b"payload", None); | ||
| assert!(errors.is_empty(), "unexpected errors: {errors:?}"); | ||
| assert_eq!(output, b"payload"); | ||
| } | ||
|
|
||
| /// A preprocessor that implements only `process_with_metadata` is still | ||
| /// callable through `process`, whose default implementation forwards with | ||
| /// no metadata. This test compiles only because `process` has a default; | ||
| /// it pins the backward-compatible surface of the trait. | ||
| #[test] | ||
| fn test_process_defaults_to_process_with_metadata() { | ||
| let mut preprocessor = TopicPrefixPreprocessor; | ||
|
|
||
| let (output, errors) = preprocessor.process(b"payload"); | ||
| assert!(errors.is_empty(), "unexpected errors: {errors:?}"); | ||
| assert_eq!(output, b"payload"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn test_registry_register_and_get() { | ||
| let mut registry = PreprocessorRegistry::new(); | ||
| registry.register("upper", Box::new(UppercasePreprocessorFactory)); | ||
|
|
||
| let factory = registry.get("upper").expect("factory must be registered"); | ||
| let mut preprocessor = factory.create(&make_config("upper")).unwrap(); | ||
| let (output, errors) = preprocessor.process(b"xyz"); | ||
| assert!(errors.is_empty(), "unexpected errors: {errors:?}"); | ||
| assert_eq!(output, b"XYZ"); | ||
|
|
||
| assert!(registry.get("missing").is_none()); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -730,11 +730,34 @@ pub struct PreprocessorConfig { | |
|
|
||
| ```rust | ||
| pub trait Preprocessor: Send + Sync { | ||
| /// Transform raw input bytes. | ||
| /// Process raw input data and return transformed data. | ||
| /// | ||
| /// # Arguments | ||
| /// * `data` - Raw input data bytes | ||
| /// | ||
| /// Returns the transformed bytes together with any non-fatal parse errors | ||
| /// encountered during transformation. | ||
| fn process(&mut self, data: &[u8]) -> (Vec<u8>, Vec<ParseError>); | ||
| /// The default implementation calls `process_with_metadata` without metadata. | ||
| fn process(&mut self, data: &[u8]) -> (Vec<u8>, Vec<ParseError>) { | ||
| self.process_with_metadata(data, None) | ||
| } | ||
|
|
||
| /// Process raw input data and return transformed data using connector metadata. | ||
| /// | ||
| /// # Arguments | ||
| /// * `data` - Raw input data bytes | ||
| /// * `metadata` - Connector metadata | ||
| /// | ||
| /// # Returns | ||
| /// The transformed data and any parse errors that occurred. | ||
| /// The default implementation just calls `process` ignoring the metadata. | ||
| fn process_with_metadata( | ||
| &mut self, | ||
| data: &[u8], | ||
| metadata: Option<&ConnectorMetadata>, | ||
| ) -> (Vec<u8>, Vec<ParseError>) { | ||
| self.process(data) | ||
| } | ||
|
|
||
| /// Create an independent copy of this preprocessor with the same configuration. | ||
| /// | ||
|
|
@@ -749,6 +772,26 @@ pub trait Preprocessor: Send + Sync { | |
| } | ||
| ``` | ||
|
|
||
| Each processing method has a default implementation that forwards to the | ||
| other, so a preprocessor needs to implement exactly one of them: | ||
|
|
||
| - Implement `process` when the transformation does not use connector | ||
| metadata. | ||
|
|
||
| - Implement `process_with_metadata` when the transformation depends on | ||
| metadata. | ||
|
|
||
| The pipeline only invokes `process_with_metadata`, which, by default, | ||
| discards the metadata and invokes `process`. | ||
|
|
||
| :::danger | ||
|
|
||
| A preprocessor that implements neither processing method compiles | ||
| without warnings, but the two default implementations will crash | ||
| the pipeline with a stack overflow. | ||
|
|
||
| ::: | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: "crash the pipeline with a overflow" -> "with a stack overflow" (or "with an infinite recursion"). The Rust source doc comment on |
||
| There are two kinds of preprocessors: streaming and message-oriented, | ||
| depending on the "message_oriented" configuration field value. | ||
|
|
||
|
|
@@ -763,6 +806,62 @@ records from the preprocessor to produce even one record. | |
| Currently only "message-oriented" preprocessors are supported with | ||
| fault-tolerance. | ||
|
|
||
| #### `ConnectorMetadata` | ||
|
|
||
| The `process_with_metadata` method receives an optional argument with type | ||
| `feldera_adapterlib::ConnectorMetadata`. Connector metadata contains | ||
| key-value attributes that the input transport endpoint attaches to each | ||
| record it produces. A preprocessor can | ||
| use these attributes to guide its transformation, for example to select | ||
| a decryption key based on the topic a record arrived on. | ||
|
|
||
| ```rust | ||
| /// Connector metadata attached to each input record. | ||
| pub struct ConnectorMetadata(BTreeMap<Variant, Variant>); | ||
| ``` | ||
|
|
||
| Attribute names are `Variant::String` keys; values are | ||
| [`Variant`](/sql/json/#the-variant-type)s. | ||
| For example, the Kafka connector may attach an attribute `kafka_timestamp` with a | ||
| type `feldera_sqllib::Timestamp`. | ||
|
|
||
| If the connector does not provide metadata, its value is `None`. | ||
|
|
||
| A preprocessor reads attributes from the `ConnectorMetadata` through two getters: | ||
|
|
||
| ```rust | ||
| impl ConnectorMetadata { | ||
| /// Returns the attribute stored under `key`, or `None` if absent. | ||
| pub fn get(&self, key: &Variant) -> Option<&Variant>; | ||
|
|
||
| /// Returns the attribute stored under the string key `name`, or `None` | ||
| /// if absent. | ||
| pub fn get_by_name(&self, name: &str) -> Option<&Variant>; | ||
| } | ||
| ``` | ||
|
|
||
| Because connectors key their attributes by string, `get_by_name` is the | ||
| common lookup; `get` accepts an arbitrary `Variant` key. For example, a | ||
| preprocessor can select its behavior based on the Kafka topic a record | ||
| arrived on: | ||
|
|
||
| ```rust | ||
| if let Some(Variant::String(topic)) = metadata.get_by_name("kafka_topic") { | ||
| // Choose a transformation based on the topic name. | ||
| } | ||
| ``` | ||
|
|
||
| Here is an example extracting a string value: | ||
|
|
||
| ```rust | ||
| // Retrieve the topic name as a string; use "" when the attribute is | ||
| // absent or is not a string. | ||
| let topic: &str = match metadata.get_by_name("kafka_topic") { | ||
| Some(Variant::String(topic)) => topic.str(), | ||
| _ => "", | ||
| }; | ||
| ``` | ||
|
|
||
| #### `PreprocessorFactory` trait | ||
|
|
||
| A factory is responsible for constructing `Preprocessor` instances from a JSON configuration | ||
|
|
@@ -876,7 +975,7 @@ tracing = { version = "0.1.40" } | |
|
|
||
| ### Configuring a preprocessor in a connector | ||
|
|
||
| Preprocessors are enabled by adding a `preprocess` field to the connector configuration. | ||
| Preprocessors are enabled by adding a `preprocessor` field to the connector configuration. | ||
| Each entry has | ||
| - a string `name` (matching a registered factory) | ||
| - a boolean `message_oriented` property | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: the doc comment says
_metadata— drop the underscore prefix in documentation (it's an implementation detail for suppressing the unused-variable warning in the default impl).