Skip to main content

connector-transform

Add a new transform under `core/connectors/sdk/src/transforms/`. Transforms mutate, filter, or convert messages between source/encode and decode/sink. Use when adding field-level transforms (add/delete/update/filter), format conversions, envelope unwrapping, or similar config-driven message manipulations for Apache Iggy connectors. Use for transform authoring. NOT for runtime internals or sink/source loops.

来源信息

仓库
apache/iggy
最近来源活动
2026年5月27日 09:04
检测到的 SKILL.md 语言
英语
星标
5,089
分支
479

安装方式

默认使用会先检查来源的 Prompt;你也可以切换为直接命令,或下载本地副本。

检查来源文件

决定是否安装前,请先阅读 SKILL.md,以及 SkillsMP 当前展示的配套文件。

正在显示 SKILL.md

SKILL.md
来源说明 · 只读预览
name
connector-transform
description
Add a new transform under `core/connectors/sdk/src/transforms/`. Transforms mutate, filter, or convert messages between source/encode and decode/sink. Use when adding field-level transforms (add/delete/update/filter), format conversions, envelope unwrapping, or similar config-driven message manipulations for Apache Iggy connectors. Use for transform authoring. NOT for runtime internals or sink/source loops.
# Adding a New Apache Iggy Connector Transform A transform implements `iggy_connector_sdk::transforms::Transform`. It runs after decode (for sinks) or before encode (for sources), in a chain configured per-connector. Config-driven, composable. > **Universal connector rules** live in > [connectors-overview](../connectors-overview/SKILL.md). This skill covers transform authoring only. ## Contents - [STOP and ask the user before](#stop-and-ask-the-user-before) - [File map](#file-map) - [The trait](#the-trait) - [Steps to add a new transform](#steps-to-add-a-new-transform) - [Rules](#rules) - [Common pitfalls](#common-pitfalls) - [Before declaring done](#before-declaring-done) ## STOP and ask the user before - Adding a new transform that mutates payload SHAPE (renaming PK fields, dropping schema-required columns) - breaks downstream sinks silently. - Introducing non-determinism (system time, RNG without seed) - breaks tests + replay. - Changing the `Transform` trait signature (sync vs async, return type) - cascades to every transform. - Making transforms hold mutable runtime state - they're constructed once and `Arc`-cloned across tasks. mutation requires interior locking and is rarely what you actually want. ## File map ```text sdk/src/transforms/ ├── mod.rs Transform trait, TransformType enum, from_config() factory, │ FieldValue + ComputedValue helpers ├── add_fields.rs AddFields - append static or computed fields to JSON ├── delete_fields.rs DeleteFields - remove fields by key ├── update_fields.rs UpdateFields - conditional field replacement ├── filter_fields.rs FilterFields - keep/drop messages by key/value pattern ├── unwrap_envelope.rs UnwrapEnvelope - promote nested field to top-level payload ├── proto_convert.rs ProtoConvert - bidirectional Proto ⇄ Json/Text/Raw/etc. ├── flatbuffer_convert.rs FlatBufferConvert ├── avro_convert.rs AvroConvert └── json/ Shared JSON helpers (path traversal, ops) ``` ## The trait ```rust // sdk/src/transforms/mod.rs - Transform pub trait Transform: Send + Sync { fn r#type(&self) -> TransformType; fn transform( &self, metadata: &TopicMetadata, message: DecodedMessage, ) -> Result<Option<DecodedMessage>, Error>; } ``` Semantics: - **`&self`** - transforms are immutable post-construction. Internal state (compiled regex, parsed paths, schema descriptors) is computed in `new()` and stored as plain struct fields. No locks needed - the whole instance is shared via `Arc<dyn Transform>`. - Takes `message` **by value** - the transform owns the `DecodedMessage` and may mutate freely without cloning. - Returns `Ok(Some(msg))` to pass through (possibly modified), `Ok(None)` to **filter** (drop silently), `Err(e)` for actual failures. - `&TopicMetadata` is for routing-aware logic. Most transforms ignore it. ## Steps to add a new transform ### 1. Create the file `sdk/src/transforms/my_transform.rs`. Apache 2.0 license header. Code reads top to bottom - config struct, then transform struct, then `impl`, then helpers. ```rust /* Apache 2.0 header */ use crate::transforms::{Transform, TransformType}; use crate::{DecodedMessage, Error, Payload, TopicMetadata}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MyTransformConfig { pub key: String, pub option: Option<bool>, } pub struct MyTransform { config: MyTransformConfig, } impl MyTransform { pub fn new(config: MyTransformConfig) -> Result<Self, Error> { if config.key.is_empty() { return Err(Error::InvalidConfigValue( "my_transform.key must not be empty".into(), )); } Ok(Self { config }) } } impl Transform for MyTransform { fn r#type(&self) -> TransformType { TransformType::MyTransform } fn transform( &self, _metadata: &TopicMetadata, mut message: DecodedMessage, ) -> Result<Option<DecodedMessage>, Error> { // Most transforms operate only on Payload::Json - non-JSON is a no-op. let Payload::Json(ref mut value) = message.payload else { return Ok(Some(message)); }; // Mutate `value` in place via simd_json::OwnedValue API. // Return Ok(None) to filter, Ok(Some(message)) to pass through. Ok(Some(message)) } } ``` ### 2. Wire into `transforms/mod.rs` Three edits, all required: (a) Module declaration + re-export at the top: ```rust mod my_transform; pub use my_transform::{MyTransform, MyTransformConfig}; ``` (b) Discriminant in `TransformType`: ```rust pub enum TransformType { AddFields, // ... MyTransform, // snake_case form in TOML: "my_transform" } ``` (c) Branch in `from_config()`: ```rust TransformType::MyTransform => { // For new transforms, prefer Error::InvalidConfigValue with formatted context - // it preserves the underlying serde_json error. Existing code uses bare // Error::InvalidConfig for most variants (UnwrapEnvelope is the exception). let cfg: MyTransformConfig = serde_json::from_value(raw.clone()) .map_err(|error| Error::InvalidConfigValue(format!("my_transform: {error}")))?; Ok(Arc::new(MyTransform::new(cfg)?)) } ``` `from_config()` returns `Arc<dyn Transform>` so the same instance is shared across the pipeline. ### 3. Document Add a TOML usage example to `sdk/README.md`: ```toml [transforms.my_transform] enabled = true key = "some_field" option = true ``` ### 4. Tests Bottom of `my_transform.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn given_matching_field_should_transform() { ... } #[test] fn given_non_json_payload_should_passthrough() { ... } #[test] fn given_filter_condition_should_drop_message() { /* assert Ok(None) */ } #[test] fn given_invalid_config_should_error_in_new() { let cfg = MyTransformConfig { key: "".into(), option: None }; assert!(matches!(MyTransform::new(cfg), Err(Error::InvalidConfigValue(_)))); } fn topic_meta() -> TopicMetadata { TopicMetadata { stream: "s".into(), topic: "t".into() } } fn json_message(json: serde_json::Value) -> DecodedMessage { /* ... */ } } ``` ## Rules ### Operate on JSON only (mostly) Most field-level transforms only make sense for `Payload::Json`. Convention (see `add_fields.rs::AddFields::transform`): silently pass through non-JSON: ```rust let Payload::Json(ref mut value) = message.payload else { return Ok(Some(message)); }; ``` Format-conversion transforms (`proto_convert`, `avro_convert`, `flatbuffer_convert`) are the exception - they explicitly convert between variants. ### Mutate `OwnedValue` in place, don't clone-then-replace `simd_json::OwnedValue` is mutable in place: ```rust if let OwnedValue::Object(ref mut obj) = value { obj.insert(key.into(), new_value); } ``` Do NOT clone the value, mutate the clone, then `message.payload = Payload::Json(new)`. The whole point of `&mut value` is in-place mutation. ### Filter semantics `Ok(None)` drops the message **silently** - no error log, no metric. By design (filters succeed when they decide to drop). For visibility, log at `debug!` level. ### Config validation Validate in `new()` and return `Err(Error::InvalidConfigValue(...))`. Don't `panic!`. Don't validate on the hot path. For transforms with regex or compiled patterns, compile once in `new()` and store the compiled form. ### Stateless except for config Transforms are constructed once at startup and `Arc`-cloned across tasks. Must be `Send + Sync`. No mutable runtime state - if you need accumulator-like behavior, that's not a `Transform`, that's a sink. ### Compiled state goes in the struct, not behind a lock Things derived from config (compiled regex, parsed paths, schema descriptors) should be computed in `new()` and stored as plain struct fields. The whole instance is shared via `Arc<dyn Transform>`, so per-field locking is unnecessary. See `sdk/src/transforms/filter_fields.rs::FilterFields::new` for the canonical pattern: regexes compile in `new()`, get stored as struct fields, are then read via `&self`. ### `transform()` is sync The trait method is sync (not `async`). Don't propose making it async - that would require pervasive change across the runtime. Schemas and other external resources should be loaded in `new()` (potentially via a fallible `try_new`), not lazily during `transform()`. ### Errors | Scenario | Variant | | ---------------------------------- | -------------------------------------------------- | | Config validation in `new()` | `Error::InvalidConfigValue("transform_name: ...")` | | Per-message validation (bad shape) | `Error::InvalidRecordValue("...")` | | Schema resolution failure | `Error::InvalidTransformer` | | Format conversion failure | `Error::InvalidPayloadType` | Returning `Err` from `transform()` stops the message and is logged. For non-fatal field-level issues (field missing when transform expects it), prefer pass-through (`Ok(Some(message))`) unless config explicitly says to fail. ## Common pitfalls 1. **Forgetting one of the three `mod.rs` edits** - module declaration, `TransformType` variant, `from_config()` branch. 2. **`#[serde(rename_all = "snake_case")]` mismatch** between enum variant name and TOML key. 3. **Cloning `OwnedValue`** when in-place mutation would work. 4. **Validating config inside `transform()`** instead of `new()`. 5. **`std::sync::Mutex` for internal state** on the hot path - precompute in `new()` and store as plain struct fields. Transforms run on every message. locks are wasted work. 6. **Returning `Err` for a filter** - filters use `Ok(None)`. ## Before declaring done ```text cargo fmt --all cargo sort --no-format --workspace cargo clippy -p iggy_connector_sdk --all-targets --all-features -- -D warnings cargo test -p iggy_connector_sdk --all-features ``` Verify by adding the transform to `core/connectors/runtime/example_config/connectors/` and running the runtime - the transform should apply to each message. If the transform changes payload shape sufficiently to break downstream sinks (e.g., renaming primary keys), call it out in the PR description. --- Discussion / help: see [AGENTS.md](../../../AGENTS.md#discussion-and-support).
在 GitHub 查看