github-actions[bot] commented on code in PR #3799: URL: https://github.com/apache/iggy/pull/3799#discussion_r4126834504
########## core/connectors/sources/fluss_source/src/lib.rs: ########## @@ -0,0 +1,1385 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod mapping; + +use async_trait::async_trait; +use fluss::client::{EARLIEST_OFFSET, FlussConnection, LogScanner}; +use fluss::config::Config; +use fluss::metadata::{DataField, RowType, TablePath}; +use fluss::record::ScanRecords; +use fluss::rpc::message::OffsetSpec; +use iggy_connector_sdk::retry::{RetryPolicy, retry_async}; +use iggy_connector_sdk::{ + ConnectorState, Error, ProducedMessage, ProducedMessages, Schema, Source, + source::SourceBatchResult, source_connector, +}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +source_connector!(FlussSource); + +const CONNECTOR_NAME: &str = "Apache Fluss source"; +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1); +const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(5); +const LOG_TABLE_TYPE: &str = "log"; +const JSON_PAYLOAD_FORMAT: &str = "json"; +const METADATA_BUCKET: &str = "_fluss_bucket"; +const METADATA_OFFSET: &str = "_fluss_offset"; +const METADATA_TIMESTAMP: &str = "_fluss_timestamp"; +const METADATA_PREFIX: &str = "_fluss_"; +const NANOS_PER_MILLI: u64 = 1_000_000; +/// A rewind is local scanner bookkeeping unless the client has to refresh the table's +/// metadata first. An error from `on_batch_result` stops the source, so that refresh gets a +/// few attempts before the failure is reported. +const REWIND_RETRY: RetryPolicy = RetryPolicy { + max_attempts: 3, + base_delay: Duration::from_millis(200), + max_delay: Duration::from_secs(1), +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct FlussSourceConfig { + pub bootstrap_servers: String, + pub database: String, + pub table: String, + /// Only `log` is accepted today. A primary-key table streams a changelog whose change + /// types need their own mapping onto messages, so the value is validated rather than + /// silently ignored. + pub table_type: Option<String>, + /// `earliest` (default), `latest`, or an explicit numeric offset applied to every bucket. + pub starting_offset: Option<String>, + /// Column projection pushed down to the server. Omit to read every column. An empty list or + /// a repeated name is rejected. + pub columns: Option<Vec<String>>, + pub poll_interval: Option<String>, + pub poll_timeout: Option<String>, + pub batch_size: Option<u32>, + /// Only `json` is accepted today. `arrow_ipc` needs the batch scanner and a different + /// offset-tracking path, so it is rejected rather than quietly downgraded. + pub payload_format: Option<String>, + /// Adds the bucket, offset and timestamp of each row under the `_fluss_` prefix, so a column + /// under that prefix is rejected while it is on. + pub include_metadata: Option<bool>, + pub sasl_username: Option<String>, + #[serde(serialize_with = "iggy_common::serde_secret::serialize_optional_secret")] Review Comment: warning: `FlussSourceConfig` derives `Serialize`, and `serialize_optional_secret` writes the plaintext, so the annotation on `sasl_password` does not redact it. `HttpSourceConfig` keeps `Deserialize` only for this reason. Drop `Serialize` at line 63 and the annotation, then the `iggy_common` dependency. ########## core/connectors/sources/fluss_source/src/mapping.rs: ########## @@ -0,0 +1,355 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; +use fluss::metadata::{DataField, DataType}; +use fluss::row::InternalRow; +use iggy_connector_sdk::Error; +use serde_json::{Map, Number, Value}; + +const MILLIS_PER_SECOND: i64 = 1_000; +const NANOS_PER_MILLI: i64 = 1_000_000; + +/// Temporal values are formatted with their full fractional precision, the way the +/// PostgreSQL source formats them. A number of milliseconds would drop the microseconds of +/// the default `TIMESTAMP(6)`. `TIMESTAMP` carries no timezone and is written without one, +/// while `TIMESTAMP_LTZ` is an instant and is written in UTC. +pub(crate) fn row_to_json( + row: &dyn InternalRow, + fields: &[DataField], +) -> Result<Map<String, Value>, Error> { + let mut object = Map::with_capacity(fields.len()); + for (position, field) in fields.iter().enumerate() { + let value = read_field(row, position, field.data_type())?; + object.insert(field.name().to_owned(), value); Review Comment: warning: `row_to_json` allocates a `String` per column and a `Value` per field, then `build_message` serializes it, so each row pays one key copy per column. Serialize the row straight into the payload, the way `influxdb_source`'s `RowView` does. ########## core/connectors/sources/fluss_source/README.md: ########## @@ -0,0 +1,90 @@ +# Apache Fluss source connector + +Reads rows from an [Apache Fluss](https://fluss.apache.org/) log table and publishes them to an Apache Iggy stream as JSON. + +Each Fluss row becomes one Apache Iggy message. Offsets are tracked per bucket and persisted through the runtime state API, so a restart resumes where the previous run stopped. Offsets only advance once the runtime acknowledges a delivered batch; a rejected batch rewinds the scanner to the last acknowledged offsets and is read again. The start position is persisted with the first poll even when it returns no rows, so a restart does not resolve `latest` again past rows written in between. + +## Configuration + +| Field | Required | Default | Description | +| ----- | -------- | ------- | ----------- | +| `bootstrap_servers` | yes | | Coordinator server address, for example `localhost:9123`. | +| `database` | yes | | Apache Fluss database name. | +| `table` | yes | | Apache Fluss table name. | +| `table_type` | no | `log` | Only `log` is accepted. See [Limitations](#limitations). | +| `starting_offset` | no | `earliest` | `earliest`, `latest` (each bucket's tail, resolved at startup), or an explicit non-negative offset. Applies only to buckets absent from the persisted state. An explicit offset is used as is for every such bucket, so it suits a table whose bucket offsets you know, such as one with a single bucket. | +| `columns` | no | all columns | Column projection pushed down to the server. Must name at least one column, and no column twice. | +| `poll_interval` | no | `1s` | Delay before each poll. | +| `poll_timeout` | no | `5s` | How long a single server poll waits for records. It and `poll_interval` cannot both be zero. | +| `batch_size` | no | `500` | Maximum records returned per poll (`scanner.log.max-poll-records`). Must be greater than 0. | +| `payload_format` | no | `json` | Only `json` is accepted. See [Limitations](#limitations). | +| `include_metadata` | no | `false` | Adds `_fluss_bucket`, `_fluss_offset` and `_fluss_timestamp` to each JSON object. While it is on, a column whose name starts with `_fluss_` is rejected at startup, since it would be overwritten. | +| `sasl_username` | no | | Enables SASL/PLAIN together with `sasl_password`. Set both or neither. | +| `sasl_password` | no | | Stored as a secret and redacted from logs and the `/stats` endpoint. | Review Comment: warning: Nothing redacts `sasl_password`: the runtime logs the raw plugin config at trace level and serves it from `GET /sources/{key}/configs/plugin`. Say so in this row instead of promising redaction, and keep the `/stats` half, which holds no config. ########## core/connectors/sources/fluss_source/README.md: ########## @@ -0,0 +1,90 @@ +# Apache Fluss source connector + +Reads rows from an [Apache Fluss](https://fluss.apache.org/) log table and publishes them to an Apache Iggy stream as JSON. + +Each Fluss row becomes one Apache Iggy message. Offsets are tracked per bucket and persisted through the runtime state API, so a restart resumes where the previous run stopped. Offsets only advance once the runtime acknowledges a delivered batch; a rejected batch rewinds the scanner to the last acknowledged offsets and is read again. The start position is persisted with the first poll even when it returns no rows, so a restart does not resolve `latest` again past rows written in between. + +## Configuration + +| Field | Required | Default | Description | +| ----- | -------- | ------- | ----------- | +| `bootstrap_servers` | yes | | Coordinator server address, for example `localhost:9123`. | +| `database` | yes | | Apache Fluss database name. | +| `table` | yes | | Apache Fluss table name. | +| `table_type` | no | `log` | Only `log` is accepted. See [Limitations](#limitations). | +| `starting_offset` | no | `earliest` | `earliest`, `latest` (each bucket's tail, resolved at startup), or an explicit non-negative offset. Applies only to buckets absent from the persisted state. An explicit offset is used as is for every such bucket, so it suits a table whose bucket offsets you know, such as one with a single bucket. | +| `columns` | no | all columns | Column projection pushed down to the server. Must name at least one column, and no column twice. | +| `poll_interval` | no | `1s` | Delay before each poll. | +| `poll_timeout` | no | `5s` | How long a single server poll waits for records. It and `poll_interval` cannot both be zero. | +| `batch_size` | no | `500` | Maximum records returned per poll (`scanner.log.max-poll-records`). Must be greater than 0. | +| `payload_format` | no | `json` | Only `json` is accepted. See [Limitations](#limitations). | +| `include_metadata` | no | `false` | Adds `_fluss_bucket`, `_fluss_offset` and `_fluss_timestamp` to each JSON object. While it is on, a column whose name starts with `_fluss_` is rejected at startup, since it would be overwritten. | +| `sasl_username` | no | | Enables SASL/PLAIN together with `sasl_password`. Set both or neither. | +| `sasl_password` | no | | Stored as a secret and redacted from logs and the `/stats` endpoint. | +| `verbose_logging` | no | `false` | Logs per-batch counts at info instead of debug. | + +## Example + +```toml +type = "source" +key = "fluss" +enabled = true +version = 0 +name = "Apache Fluss source" +path = "libiggy_connector_fluss_source" + +[[streams]] +stream = "fluss_events" +topic = "events" +schema = "json" +batch_length = 100 + +[plugin_config] +bootstrap_servers = "localhost:9123" +database = "mydb" +table = "events" +poll_interval = "1s" +batch_size = 500 +``` + +Bring up a local cluster with the [official Docker compose recipe](https://fluss.apache.org/docs/install-deploy/deploying-with-docker/) (ZooKeeper, one coordinator server, one tablet server), create the stream and topic with the Apache Iggy CLI, then start the connectors runtime. + +## Type mapping + +| Apache Fluss type | JSON | +| ----------------- | ---- | +| `BOOLEAN` | boolean | +| `TINYINT`, `SMALLINT`, `INT`, `BIGINT` | number | +| `FLOAT`, `DOUBLE` | number, `null` when not finite | +| `CHAR`, `STRING` | string | +| `DECIMAL` | string, to keep the full precision | +| `DATE` | string, `2024-02-29` | +| `TIME` | string, `12:34:56.789` | +| `TIMESTAMP` | string without a timezone, `2023-11-14 22:13:20.123456` | +| `TIMESTAMP_LTZ` | string in UTC, RFC 3339, `2023-11-14T22:13:20.123456+00:00` | +| `BINARY`, `BYTES` | base64 string | +| `ARRAY`, `MAP`, `ROW` | not supported, rejected at startup | + +Temporal values keep every fractional digit the column holds, so the default `TIMESTAMP(6)` keeps its microseconds. They are formatted the same way the PostgreSQL source formats them: `TIMESTAMP` carries no timezone and is written without one, while `TIMESTAMP_LTZ` is an instant and is written in UTC. + +Rows are decoded with the table schema read when the connector starts. A row written before a column was added carries `null` for it, and a column added after startup stays out of the output until the connector restarts. + +Every message carries an `id` derived from its bucket and offset, so a consumer can spot a record replayed after an at-least-once redelivery (the Apache Iggy server does not deduplicate on it), and an `origin_timestamp` taken from the Fluss record timestamp. Review Comment: warning: `origin_timestamp` never reaches Apache Iggy: the runtime builds the message without it and Iggy stamps its own time, so the value this connector sets is dropped. Say so, and point readers at `_fluss_timestamp` when `include_metadata` is on. ########## core/connectors/sources/fluss_source/src/lib.rs: ########## @@ -0,0 +1,1385 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod mapping; + +use async_trait::async_trait; +use fluss::client::{EARLIEST_OFFSET, FlussConnection, LogScanner}; +use fluss::config::Config; +use fluss::metadata::{DataField, RowType, TablePath}; +use fluss::record::ScanRecords; +use fluss::rpc::message::OffsetSpec; +use iggy_connector_sdk::retry::{RetryPolicy, retry_async}; +use iggy_connector_sdk::{ + ConnectorState, Error, ProducedMessage, ProducedMessages, Schema, Source, + source::SourceBatchResult, source_connector, +}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +source_connector!(FlussSource); + +const CONNECTOR_NAME: &str = "Apache Fluss source"; +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1); +const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(5); +const LOG_TABLE_TYPE: &str = "log"; +const JSON_PAYLOAD_FORMAT: &str = "json"; +const METADATA_BUCKET: &str = "_fluss_bucket"; +const METADATA_OFFSET: &str = "_fluss_offset"; +const METADATA_TIMESTAMP: &str = "_fluss_timestamp"; +const METADATA_PREFIX: &str = "_fluss_"; +const NANOS_PER_MILLI: u64 = 1_000_000; +/// A rewind is local scanner bookkeeping unless the client has to refresh the table's +/// metadata first. An error from `on_batch_result` stops the source, so that refresh gets a +/// few attempts before the failure is reported. +const REWIND_RETRY: RetryPolicy = RetryPolicy { + max_attempts: 3, + base_delay: Duration::from_millis(200), + max_delay: Duration::from_secs(1), +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct FlussSourceConfig { + pub bootstrap_servers: String, + pub database: String, + pub table: String, + /// Only `log` is accepted today. A primary-key table streams a changelog whose change + /// types need their own mapping onto messages, so the value is validated rather than + /// silently ignored. + pub table_type: Option<String>, + /// `earliest` (default), `latest`, or an explicit numeric offset applied to every bucket. + pub starting_offset: Option<String>, + /// Column projection pushed down to the server. Omit to read every column. An empty list or + /// a repeated name is rejected. + pub columns: Option<Vec<String>>, + pub poll_interval: Option<String>, + pub poll_timeout: Option<String>, + pub batch_size: Option<u32>, + /// Only `json` is accepted today. `arrow_ipc` needs the batch scanner and a different + /// offset-tracking path, so it is rejected rather than quietly downgraded. + pub payload_format: Option<String>, + /// Adds the bucket, offset and timestamp of each row under the `_fluss_` prefix, so a column + /// under that prefix is rejected while it is on. + pub include_metadata: Option<bool>, + pub sasl_username: Option<String>, + #[serde(serialize_with = "iggy_common::serde_secret::serialize_optional_secret")] + pub sasl_password: Option<SecretString>, + pub verbose_logging: Option<bool>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct State { + /// Next offset to read per bucket. Absent buckets fall back to the configured start. + bucket_offsets: HashMap<i32, i64>, + messages_produced: u64, +} + +#[derive(Debug, Clone, Copy)] +enum StartingOffset { + Earliest, + Latest, + Explicit(i64), +} + +impl FromStr for StartingOffset { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "earliest" => Ok(StartingOffset::Earliest), + "latest" => Ok(StartingOffset::Latest), + other => match other.parse::<i64>() { + Ok(offset) if offset >= 0 => Ok(StartingOffset::Explicit(offset)), + _ => Err(Error::InitError(format!( + "invalid starting_offset '{other}' for {CONNECTOR_NAME}, expected 'earliest', \ + 'latest' or a non-negative offset" + ))), + }, + } + } +} + +pub struct FlussSource { + id: u32, + config: FlussSourceConfig, + table_path: TablePath, + poll_interval: Duration, + poll_timeout: Duration, + include_metadata: bool, + verbose_logging: bool, + connection: Option<FlussConnection>, + scanner: Option<LogScanner>, + fields: Vec<DataField>, + state: Mutex<State>, + pending_state: Mutex<Option<State>>, + /// Set when `open()` resolved start offsets that are not on disk yet. The next poll carries + /// them through the ACK handshake even without rows, so a restart before the first row does + /// not resolve `latest` again and skip what was written in between. + start_offsets_unsaved: AtomicBool, + rows_skipped: AtomicU64, +} + +/// `FlussConnection` and `LogScanner` do not implement `Debug`, so the derive is replaced by +/// a hand-written one that reports connection presence instead of client internals. +impl std::fmt::Debug for FlussSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("FlussSource") + .field("id", &self.id) + .field("table_path", &self.table_path) + .field("poll_interval", &self.poll_interval) + .field("poll_timeout", &self.poll_timeout) + .field("include_metadata", &self.include_metadata) + .field("columns", &self.fields.len()) + .field("opened", &self.scanner.is_some()) + .finish_non_exhaustive() + } +} + +impl FlussSource { + pub fn new(id: u32, config: FlussSourceConfig, state: Option<ConnectorState>) -> Self { + let poll_interval = parse_duration( + config.poll_interval.as_deref(), + DEFAULT_POLL_INTERVAL, + "poll_interval", + id, + ); + let poll_timeout = parse_duration( + config.poll_timeout.as_deref(), + DEFAULT_POLL_TIMEOUT, + "poll_timeout", + id, + ); + let include_metadata = config.include_metadata.unwrap_or(false); + let verbose_logging = config.verbose_logging.unwrap_or(false); + let table_path = TablePath::new(config.database.clone(), config.table.clone()); + + let restored_state = state + .and_then(|state| state.deserialize::<State>(CONNECTOR_NAME, id)) + .inspect(|state| { + info!( + "Restored state for {CONNECTOR_NAME} connector with ID: {id}. \ + Buckets tracked: {}, messages produced: {}", + state.bucket_offsets.len(), + state.messages_produced + ); + }); + + FlussSource { + id, + config, + table_path, + poll_interval, + poll_timeout, + include_metadata, + verbose_logging, + connection: None, + scanner: None, + fields: Vec::new(), + state: Mutex::new(restored_state.unwrap_or_default()), + pending_state: Mutex::new(None), + start_offsets_unsaved: AtomicBool::new(false), + rows_skipped: AtomicU64::new(0), + } + } + + fn serialize_state(&self, state: &State) -> Option<ConnectorState> { + ConnectorState::serialize(state, CONNECTOR_NAME, self.id) + } + + fn client_config(&self) -> Config { + let mut config = Config { + bootstrap_servers: self.config.bootstrap_servers.clone(), + ..Config::default() + }; + if let Some(batch_size) = self.config.batch_size { + config.scanner_log_max_poll_records = batch_size as usize; + } + if let (Some(username), Some(password)) = + (&self.config.sasl_username, &self.config.sasl_password) + { + config.security_protocol = "sasl".to_owned(); + config.security_sasl_mechanism = "PLAIN".to_owned(); + config.security_sasl_username = username.clone(); + config.security_sasl_password = password.expose_secret().to_owned(); + } + config + } + + fn validate_config(&self) -> Result<StartingOffset, Error> { + for (field, value) in [ + ("bootstrap_servers", &self.config.bootstrap_servers), + ("database", &self.config.database), + ("table", &self.config.table), + ] { + if value.trim().is_empty() { + return Err(Error::InitError(format!( + "{field} for {CONNECTOR_NAME} must not be empty" + ))); + } + } + + let table_type = self.config.table_type.as_deref().unwrap_or(LOG_TABLE_TYPE); + if table_type != LOG_TABLE_TYPE { + return Err(Error::InitError(format!( + "{CONNECTOR_NAME} supports only table_type '{LOG_TABLE_TYPE}', got '{table_type}'. \ + Primary-key changelog tables are not supported yet" + ))); + } + + let payload_format = self + .config + .payload_format + .as_deref() + .unwrap_or(JSON_PAYLOAD_FORMAT); + if payload_format != JSON_PAYLOAD_FORMAT { + return Err(Error::InitError(format!( + "{CONNECTOR_NAME} supports only payload_format '{JSON_PAYLOAD_FORMAT}', got '{payload_format}'" + ))); + } + + if let Some(columns) = &self.config.columns { + if columns.is_empty() { + return Err(Error::InitError(format!( + "columns for {CONNECTOR_NAME} must name at least one column, or be left out to \ + read every column" + ))); + } + let mut seen = HashSet::with_capacity(columns.len()); + if let Some(duplicate) = columns.iter().find(|column| !seen.insert(column.as_str())) { + return Err(Error::InitError(format!( + "column '{duplicate}' is listed more than once in columns for {CONNECTOR_NAME}" + ))); + } + } + + // The client accepts a zero limit, which makes every poll come back empty. + if self.config.batch_size == Some(0) { + return Err(Error::InitError(format!( + "batch_size for {CONNECTOR_NAME} must be greater than 0" + ))); + } + // One without the other would connect without SASL and only fail at the server. + if self.config.sasl_username.is_some() != self.config.sasl_password.is_some() { + return Err(Error::InitError(format!( + "sasl_username and sasl_password for {CONNECTOR_NAME} must be set together" + ))); + } + // With neither a pause before the poll nor a server-side wait, an idle table would be + // polled in a tight loop. + if self.poll_interval.is_zero() && self.poll_timeout.is_zero() { + return Err(Error::InitError(format!( + "poll_interval and poll_timeout for {CONNECTOR_NAME} cannot both be zero" + ))); + } + + self.config + .starting_offset + .as_deref() + .unwrap_or("earliest") + .parse() + } + + /// Only the table's current buckets are subscribed. A bucket already present in the restored + /// state keeps its offset and every other one starts at the given default, so a widened + /// bucket count does not rewind buckets that were already consumed, and an offset saved for + /// a bucket the table no longer has is dropped. + fn resolve_start_offsets( + bucket_count: i32, + start_offset: i64, + tracked: &HashMap<i32, i64>, + ) -> HashMap<i32, i64> { + (0..bucket_count) + .map(|bucket| { + ( + bucket, + tracked.get(&bucket).copied().unwrap_or(start_offset), + ) + }) + .collect() + } + + /// Same rule as [`Self::resolve_start_offsets`], with each untracked bucket starting at its + /// current tail. + async fn resolve_latest_offsets( + &self, + connection: &FlussConnection, + bucket_count: i32, + tracked: &HashMap<i32, i64>, + ) -> Result<HashMap<i32, i64>, Error> { + let missing: Vec<i32> = (0..bucket_count) + .filter(|bucket| !tracked.contains_key(bucket)) + .collect(); + let tails = if missing.is_empty() { + HashMap::new() + } else { + let admin = connection.get_admin().map_err(connection_error)?; + admin + .list_offsets(&self.table_path, &missing, OffsetSpec::Latest) + .await + .map_err(connection_error)? + }; + (0..bucket_count) + .map(|bucket| { + let offset = match tracked.get(&bucket) { + Some(offset) => *offset, + None => tails.get(&bucket).copied().ok_or_else(|| { + Error::InitError(format!( + "Apache Fluss returned no latest offset for bucket {bucket} of table '{}'", + self.table_path + )) + })?, + }; + Ok((bucket, offset)) + }) + .collect() + } + + /// A row that cannot be converted fails the same way on every read, so failing the batch + /// would read it again forever. It is dropped and logged with its position instead, and the + /// offsets still move past it. + async fn build_batch( + &self, + records: &ScanRecords, + ) -> Result<(Vec<ProducedMessage>, Option<ConnectorState>), Error> { + let buckets = records.records_by_buckets(); + let mut messages = Vec::with_capacity(records.count()); + let mut polled_offsets = HashMap::with_capacity(buckets.len()); + let mut skipped = 0u64; + for (bucket, bucket_records) in buckets { + let bucket_id = bucket.bucket_id(); + for record in bucket_records { + let message = mapping::row_to_json(record.row(), &self.fields).and_then(|row| { + self.build_message(bucket_id, record.offset(), record.timestamp(), row) + }); + match message { + Ok(message) => messages.push(message), + Err(error) => { Review Comment: warning: The skip branch treats every `row_to_json` error as an unconvertible value, so a failed column read also drops the row and advances past it. Fail the batch for a retriable read failure and skip only `Error::InvalidRecordValue`. ########## core/integration/tests/connectors/fixtures/fluss/container.rs: ########## @@ -0,0 +1,140 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use crate::connectors::fixtures; +use integration::harness::TestBinaryError; +use std::net::TcpListener; +use testcontainers_modules::testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use testcontainers_modules::testcontainers::{ContainerAsync, GenericImage, ImageExt}; + +const FLUSS_IMAGE: &str = "apache/fluss"; +const FLUSS_VERSION: &str = "1.0.0"; +const READY_MESSAGE: &str = "Registered tablet server 0"; + +pub(super) const ENV_SOURCE_BOOTSTRAP_SERVERS: &str = + "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_BOOTSTRAP_SERVERS"; +pub(super) const ENV_SOURCE_DATABASE: &str = "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_DATABASE"; +pub(super) const ENV_SOURCE_TABLE: &str = "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_TABLE"; +pub(super) const ENV_SOURCE_COLUMNS: &str = "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_COLUMNS"; +pub(super) const ENV_SOURCE_STARTING_OFFSET: &str = + "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_STARTING_OFFSET"; +pub(super) const ENV_SOURCE_POLL_INTERVAL: &str = + "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_POLL_INTERVAL"; +pub(super) const ENV_SOURCE_INCLUDE_METADATA: &str = + "IGGY_CONNECTORS_SOURCE_FLUSS_PLUGIN_CONFIG_INCLUDE_METADATA"; +pub(super) const ENV_SOURCE_STREAMS_0_STREAM: &str = + "IGGY_CONNECTORS_SOURCE_FLUSS_STREAMS_0_STREAM"; +pub(super) const ENV_SOURCE_STREAMS_0_TOPIC: &str = "IGGY_CONNECTORS_SOURCE_FLUSS_STREAMS_0_TOPIC"; +pub(super) const ENV_SOURCE_STREAMS_0_SCHEMA: &str = + "IGGY_CONNECTORS_SOURCE_FLUSS_STREAMS_0_SCHEMA"; +pub(super) const ENV_SOURCE_PATH: &str = "IGGY_CONNECTORS_SOURCE_FLUSS_PATH"; + +/// A whole Fluss cluster (embedded ZooKeeper, coordinator server, tablet server) inside one +/// container. +/// +/// The image ships `local-cluster.sh`, which starts the same three processes, but it rewrites +/// the tablet server's bind port to 0 so it cannot collide with the coordinator. A random port +/// inside the container cannot be published to the host, so the tablet server is given an +/// explicit second port here instead. +/// +/// Both servers advertise `localhost`, which resolves to the coordinator/tablet pair inside the +/// container and to the published ports from the test process. That only holds while the host +/// and container port numbers match, so free host ports are reserved up front and mapped +/// one-to-one rather than letting Docker assign them. +pub(super) struct FlussContainer { + #[allow(dead_code)] + container: ContainerAsync<GenericImage>, + pub(super) bootstrap_servers: String, +} + +impl FlussContainer { + pub(super) async fn start() -> Result<Self, TestBinaryError> { + let coordinator_port = reserve_host_port()?; Review Comment: nit: Both `reserve_host_port()` calls release their listener on return, so the kernel can hand one free port to both and the two servers then collide. Read the second port before dropping the first listener. ########## core/connectors/sources/fluss_source/src/lib.rs: ########## @@ -0,0 +1,1385 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +mod mapping; + +use async_trait::async_trait; +use fluss::client::{EARLIEST_OFFSET, FlussConnection, LogScanner}; +use fluss::config::Config; +use fluss::metadata::{DataField, RowType, TablePath}; +use fluss::record::ScanRecords; +use fluss::rpc::message::OffsetSpec; +use iggy_connector_sdk::retry::{RetryPolicy, retry_async}; +use iggy_connector_sdk::{ + ConnectorState, Error, ProducedMessage, ProducedMessages, Schema, Source, + source::SourceBatchResult, source_connector, +}; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +source_connector!(FlussSource); + +const CONNECTOR_NAME: &str = "Apache Fluss source"; +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(1); +const DEFAULT_POLL_TIMEOUT: Duration = Duration::from_secs(5); +const LOG_TABLE_TYPE: &str = "log"; +const JSON_PAYLOAD_FORMAT: &str = "json"; +const METADATA_BUCKET: &str = "_fluss_bucket"; +const METADATA_OFFSET: &str = "_fluss_offset"; +const METADATA_TIMESTAMP: &str = "_fluss_timestamp"; +const METADATA_PREFIX: &str = "_fluss_"; +const NANOS_PER_MILLI: u64 = 1_000_000; +/// A rewind is local scanner bookkeeping unless the client has to refresh the table's +/// metadata first. An error from `on_batch_result` stops the source, so that refresh gets a +/// few attempts before the failure is reported. +const REWIND_RETRY: RetryPolicy = RetryPolicy { + max_attempts: 3, + base_delay: Duration::from_millis(200), + max_delay: Duration::from_secs(1), +}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct FlussSourceConfig { + pub bootstrap_servers: String, + pub database: String, + pub table: String, + /// Only `log` is accepted today. A primary-key table streams a changelog whose change + /// types need their own mapping onto messages, so the value is validated rather than + /// silently ignored. + pub table_type: Option<String>, + /// `earliest` (default), `latest`, or an explicit numeric offset applied to every bucket. + pub starting_offset: Option<String>, + /// Column projection pushed down to the server. Omit to read every column. An empty list or + /// a repeated name is rejected. + pub columns: Option<Vec<String>>, + pub poll_interval: Option<String>, + pub poll_timeout: Option<String>, + pub batch_size: Option<u32>, + /// Only `json` is accepted today. `arrow_ipc` needs the batch scanner and a different + /// offset-tracking path, so it is rejected rather than quietly downgraded. + pub payload_format: Option<String>, + /// Adds the bucket, offset and timestamp of each row under the `_fluss_` prefix, so a column + /// under that prefix is rejected while it is on. + pub include_metadata: Option<bool>, + pub sasl_username: Option<String>, + #[serde(serialize_with = "iggy_common::serde_secret::serialize_optional_secret")] + pub sasl_password: Option<SecretString>, + pub verbose_logging: Option<bool>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +struct State { + /// Next offset to read per bucket. Absent buckets fall back to the configured start. + bucket_offsets: HashMap<i32, i64>, + messages_produced: u64, +} + +#[derive(Debug, Clone, Copy)] +enum StartingOffset { + Earliest, + Latest, + Explicit(i64), +} + +impl FromStr for StartingOffset { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + match value { + "earliest" => Ok(StartingOffset::Earliest), + "latest" => Ok(StartingOffset::Latest), + other => match other.parse::<i64>() { + Ok(offset) if offset >= 0 => Ok(StartingOffset::Explicit(offset)), + _ => Err(Error::InitError(format!( + "invalid starting_offset '{other}' for {CONNECTOR_NAME}, expected 'earliest', \ + 'latest' or a non-negative offset" + ))), + }, + } + } +} + +pub struct FlussSource { + id: u32, + config: FlussSourceConfig, + table_path: TablePath, + poll_interval: Duration, + poll_timeout: Duration, + include_metadata: bool, + verbose_logging: bool, + connection: Option<FlussConnection>, + scanner: Option<LogScanner>, + fields: Vec<DataField>, + state: Mutex<State>, + pending_state: Mutex<Option<State>>, + /// Set when `open()` resolved start offsets that are not on disk yet. The next poll carries + /// them through the ACK handshake even without rows, so a restart before the first row does + /// not resolve `latest` again and skip what was written in between. + start_offsets_unsaved: AtomicBool, + rows_skipped: AtomicU64, +} + +/// `FlussConnection` and `LogScanner` do not implement `Debug`, so the derive is replaced by +/// a hand-written one that reports connection presence instead of client internals. +impl std::fmt::Debug for FlussSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("FlussSource") + .field("id", &self.id) + .field("table_path", &self.table_path) + .field("poll_interval", &self.poll_interval) + .field("poll_timeout", &self.poll_timeout) + .field("include_metadata", &self.include_metadata) + .field("columns", &self.fields.len()) + .field("opened", &self.scanner.is_some()) + .finish_non_exhaustive() + } +} + +impl FlussSource { + pub fn new(id: u32, config: FlussSourceConfig, state: Option<ConnectorState>) -> Self { + let poll_interval = parse_duration( + config.poll_interval.as_deref(), + DEFAULT_POLL_INTERVAL, + "poll_interval", + id, + ); + let poll_timeout = parse_duration( + config.poll_timeout.as_deref(), + DEFAULT_POLL_TIMEOUT, + "poll_timeout", + id, + ); + let include_metadata = config.include_metadata.unwrap_or(false); + let verbose_logging = config.verbose_logging.unwrap_or(false); + let table_path = TablePath::new(config.database.clone(), config.table.clone()); + + let restored_state = state + .and_then(|state| state.deserialize::<State>(CONNECTOR_NAME, id)) + .inspect(|state| { + info!( + "Restored state for {CONNECTOR_NAME} connector with ID: {id}. \ + Buckets tracked: {}, messages produced: {}", + state.bucket_offsets.len(), + state.messages_produced + ); + }); + + FlussSource { + id, + config, + table_path, + poll_interval, + poll_timeout, + include_metadata, + verbose_logging, + connection: None, + scanner: None, + fields: Vec::new(), + state: Mutex::new(restored_state.unwrap_or_default()), + pending_state: Mutex::new(None), + start_offsets_unsaved: AtomicBool::new(false), + rows_skipped: AtomicU64::new(0), + } + } + + fn serialize_state(&self, state: &State) -> Option<ConnectorState> { + ConnectorState::serialize(state, CONNECTOR_NAME, self.id) + } + + fn client_config(&self) -> Config { + let mut config = Config { + bootstrap_servers: self.config.bootstrap_servers.clone(), + ..Config::default() + }; + if let Some(batch_size) = self.config.batch_size { + config.scanner_log_max_poll_records = batch_size as usize; + } + if let (Some(username), Some(password)) = + (&self.config.sasl_username, &self.config.sasl_password) + { + config.security_protocol = "sasl".to_owned(); + config.security_sasl_mechanism = "PLAIN".to_owned(); + config.security_sasl_username = username.clone(); + config.security_sasl_password = password.expose_secret().to_owned(); + } + config + } + + fn validate_config(&self) -> Result<StartingOffset, Error> { + for (field, value) in [ + ("bootstrap_servers", &self.config.bootstrap_servers), + ("database", &self.config.database), + ("table", &self.config.table), + ] { + if value.trim().is_empty() { + return Err(Error::InitError(format!( + "{field} for {CONNECTOR_NAME} must not be empty" + ))); + } + } + + let table_type = self.config.table_type.as_deref().unwrap_or(LOG_TABLE_TYPE); + if table_type != LOG_TABLE_TYPE { + return Err(Error::InitError(format!( + "{CONNECTOR_NAME} supports only table_type '{LOG_TABLE_TYPE}', got '{table_type}'. \ + Primary-key changelog tables are not supported yet" + ))); + } + + let payload_format = self + .config + .payload_format + .as_deref() + .unwrap_or(JSON_PAYLOAD_FORMAT); + if payload_format != JSON_PAYLOAD_FORMAT { + return Err(Error::InitError(format!( + "{CONNECTOR_NAME} supports only payload_format '{JSON_PAYLOAD_FORMAT}', got '{payload_format}'" + ))); + } + + if let Some(columns) = &self.config.columns { + if columns.is_empty() { + return Err(Error::InitError(format!( + "columns for {CONNECTOR_NAME} must name at least one column, or be left out to \ + read every column" + ))); + } + let mut seen = HashSet::with_capacity(columns.len()); + if let Some(duplicate) = columns.iter().find(|column| !seen.insert(column.as_str())) { + return Err(Error::InitError(format!( + "column '{duplicate}' is listed more than once in columns for {CONNECTOR_NAME}" + ))); + } + } + + // The client accepts a zero limit, which makes every poll come back empty. + if self.config.batch_size == Some(0) { + return Err(Error::InitError(format!( + "batch_size for {CONNECTOR_NAME} must be greater than 0" + ))); + } + // One without the other would connect without SASL and only fail at the server. + if self.config.sasl_username.is_some() != self.config.sasl_password.is_some() { + return Err(Error::InitError(format!( + "sasl_username and sasl_password for {CONNECTOR_NAME} must be set together" + ))); + } + // With neither a pause before the poll nor a server-side wait, an idle table would be + // polled in a tight loop. + if self.poll_interval.is_zero() && self.poll_timeout.is_zero() { + return Err(Error::InitError(format!( + "poll_interval and poll_timeout for {CONNECTOR_NAME} cannot both be zero" + ))); + } + + self.config + .starting_offset + .as_deref() + .unwrap_or("earliest") + .parse() + } + + /// Only the table's current buckets are subscribed. A bucket already present in the restored + /// state keeps its offset and every other one starts at the given default, so a widened + /// bucket count does not rewind buckets that were already consumed, and an offset saved for + /// a bucket the table no longer has is dropped. + fn resolve_start_offsets( + bucket_count: i32, + start_offset: i64, + tracked: &HashMap<i32, i64>, + ) -> HashMap<i32, i64> { + (0..bucket_count) + .map(|bucket| { + ( + bucket, + tracked.get(&bucket).copied().unwrap_or(start_offset), + ) + }) + .collect() + } + + /// Same rule as [`Self::resolve_start_offsets`], with each untracked bucket starting at its + /// current tail. + async fn resolve_latest_offsets( + &self, + connection: &FlussConnection, + bucket_count: i32, + tracked: &HashMap<i32, i64>, + ) -> Result<HashMap<i32, i64>, Error> { + let missing: Vec<i32> = (0..bucket_count) + .filter(|bucket| !tracked.contains_key(bucket)) + .collect(); + let tails = if missing.is_empty() { + HashMap::new() + } else { + let admin = connection.get_admin().map_err(connection_error)?; + admin + .list_offsets(&self.table_path, &missing, OffsetSpec::Latest) + .await + .map_err(connection_error)? + }; + (0..bucket_count) + .map(|bucket| { + let offset = match tracked.get(&bucket) { + Some(offset) => *offset, + None => tails.get(&bucket).copied().ok_or_else(|| { + Error::InitError(format!( + "Apache Fluss returned no latest offset for bucket {bucket} of table '{}'", + self.table_path + )) + })?, + }; + Ok((bucket, offset)) + }) + .collect() + } + + /// A row that cannot be converted fails the same way on every read, so failing the batch + /// would read it again forever. It is dropped and logged with its position instead, and the + /// offsets still move past it. + async fn build_batch( + &self, + records: &ScanRecords, + ) -> Result<(Vec<ProducedMessage>, Option<ConnectorState>), Error> { + let buckets = records.records_by_buckets(); + let mut messages = Vec::with_capacity(records.count()); + let mut polled_offsets = HashMap::with_capacity(buckets.len()); + let mut skipped = 0u64; + for (bucket, bucket_records) in buckets { + let bucket_id = bucket.bucket_id(); + for record in bucket_records { + let message = mapping::row_to_json(record.row(), &self.fields).and_then(|row| { + self.build_message(bucket_id, record.offset(), record.timestamp(), row) + }); + match message { + Ok(message) => messages.push(message), + Err(error) => { + skipped += 1; + error!( + "{CONNECTOR_NAME} connector with ID: {} skipped the row at bucket \ + {bucket_id}, offset {}, which it cannot convert: {error}", + self.id, + record.offset() + ); + } + } + } + if let Some(last) = bucket_records.last() { + polled_offsets.insert(bucket_id, last.offset() + 1); + } + } + if skipped > 0 { + self.rows_skipped.fetch_add(skipped, Ordering::Relaxed); Review Comment: nit: `rows_skipped` counts an unconvertible row again after every Nack, because the rewind sends it through `build_batch` once more, so the close log overstates the drops. Count a row once, by its bucket and offset. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
