Bug description
When a sink's [[streams]] entry sets schema = "avro", "proto" or "flatbuffer", the sink receives its messages as the wrong Payload variant.
The runtime tags each batch with the decoder's schema rather than the type the decoder returned. Those three decoders all return Payload::Json under the config the runtime gives them, so the tag and the bytes disagree. The SDK rebuilds the payload from the tag, so a sink is handed a Payload::Avro holding JSON.
What happens next depends on the sink. Sinks that only handle Payload::Json — ClickHouse, Delta, Elasticsearch, Meilisearch, Quickwit, Iceberg — log the message as an unsupported payload type and drop it. Sinks that accept the raw variants — S3, HTTP, SurrealDB — base64-encode the JSON text and store it as though it were Avro bytes.
Expected: the sink receives Payload::Json, because that is what the decoder produced.
Root cause
// core/connectors/runtime/src/sink.rs:359-363
let messages_metadata = MessagesMetadata {
partition_id,
current_offset,
schema: decoder.schema(), // Schema::Avro
};
The bytes sent alongside come from Payload::try_into_vec(), which for the Payload::Json the Avro decoder returned gives JSON (core/connectors/sdk/src/lib.rs:163-165). The same tag is put on RawMessages at :731. The SDK rebuilds from it (sdk/src/sink.rs:199, then lib.rs:263: Schema::Avro => Payload::Avro(value)).
No connector config avoids this. The runtime builds the Avro decoder with ..AvroConfig::default() (sink.rs:532-536), so extract_as_json cannot be turned off from a connector file, and Proto and FlatBuffer get Schema::decoder() defaults with no knob at all.
Sources are unaffected: a source plugin tags its own ProducedMessages.schema and the encoder takes the Payload directly (source.rs:430-435), with no rebuild step.
Which schemas are affected
schema |
decoder returns |
batch tagged |
sink receives |
json, text, raw |
matching variant |
matching |
works |
avro |
Payload::Json (decoders/avro.rs:169) |
Avro |
Payload::Avro |
flatbuffer |
Payload::Json (decoders/flatbuffer.rs:109) |
FlatBuffer |
Payload::FlatBuffer |
proto |
Payload::Json, if the message is an Any (decoders/proto.rs:529) |
Proto |
Payload::Raw |
The three that work are the ones whose decoder output matches its own schema(). Avro and FlatBuffer default to extract_as_json: true; Proto with no schema configured falls through to decode_as_any (proto.rs:264-265), which also returns JSON. Proto ends up as Payload::Raw rather than Payload::Proto because try_into_payload re-decodes the bytes as a prost_types::Any and falls back to Raw when that fails (lib.rs:260).
Impact
For the Json-only sinks the batch is lost without an error being raised. The ClickHouse sink ends up with an empty body and returns Ok(()) (sinks/clickhouse_sink/src/sink.rs:146-152), and offsets are committed with the poll request (AutoCommit::When(AutoCommitWhen::PollingMessages), runtime/src/sink.rs:522) — the SDK documents that mode as sending "the commit with the poll request itself, before your code sees the batch" (core/sdk/src/clients/consumer.rs:501). So the offsets advance before the sink runs, and restarting the connector does not redeliver.
The batch is still counted as consumed. A run of 100 messages logs Consumed 100 messages, and the connector reports Processed 0 messages. at close.
Related to #3950 and PR #3954, which bind the sink's return status and let a sink defer the offset commit. Neither covers this: the ClickHouse sink returns Ok(()) on an empty body, so even with offset_commit = after_consuming the batch would be committed as successfully written.
Suggested fix
Tag the batch with the payload's type rather than the decoder's. Changing each decoder's schema() would not be enough on its own: transforms run after decoding and can change the type again, and the Proto decoder returns either Json or Raw depending on the path it takes.
Adding a Payload::schema() and using it where MessagesMetadata and RawMessages are built (runtime/src/sink.rs:362 and :731) covers all three formats in one place. I can send a PR either way; say which you prefer.
Compatibility
The fix changes what three sinks write. S3, HTTP and SurrealDB match on Payload::Avro and base64-encode it, so an avro stream reaches them today as base64 of JSON text, stored as though it were Avro bytes. Nothing reports this. After the fix they receive Payload::Json and write a JSON document, so anyone consuming their current output would see it change shape. That output is corrupt.
The Json-only sinks go from dropping the batch to writing it correctly. Streams configured json, text or raw are unaffected, because the decoder's schema and the payload's type already agree and the tag does not move.
Two related gaps:
StreamConsumerConfig only exposes avro_schema_json and avro_schema_path, so flatbuffer and proto cannot be given a schema through the runtime at all.
- Nothing exercises a non-JSON schema through the runtime's sink path, and all ten configs under
runtime/example_config/connectors/ use schema = "json". A test across the decoders would catch this class of problem.
Affected area / component
Connectors
Deployment
Compiled from source
Versions
master @ 412014a — iggy-connectors 0.5.0-edge.6, iggy_connector_sdk 0.4.0-edge.3, iggy_connector_clickhouse_sink 0.2.0-edge.4
Hardware / environment
macOS arm64, local build; not hardware-dependent.
Sample code
type = "sink"
key = "clickhouse"
enabled = true
version = 0
name = "ClickHouse sink"
path = "target/release/libiggy_connector_clickhouse_sink"
verbose = true
[[streams]]
stream = "repro"
topics = ["avro"]
schema = "avro"
avro_schema_json = '{"type":"record","name":"Event","fields":[{"name":"id","type":"long"},{"name":"name","type":"string"}]}'
batch_length = 100
poll_interval = "5ms"
consumer_group = "repro-sink"
[plugin_config]
url = "http://localhost:8123"
database = "default"
table = "repro"
insert_format = "row_binary"
Producer — an ordinary IggyProducer, with each payload an Avro datum for that schema:
let schema = apache_avro::Schema::parse_str(SCHEMA)?;
let record = apache_avro::types::Value::Record(vec![
("id".to_owned(), apache_avro::types::Value::Long(id)),
("name".to_owned(), apache_avro::types::Value::String(format!("row-{id}"))),
]);
let datum = apache_avro::writer::datum::GenericDatumWriter::builder(&schema)
.build()?
.write_value_to_vec(record)?;
let message = IggyMessage::builder().payload(datum.into()).build()?;
Logs
100 Avro messages into a single-partition topic, ClickHouse sink in row_binary mode:
INFO iggy_connectors::sink: Processing 100 messages for sink connector with ID: 1
ERROR connector: connector_target="iggy_connector_clickhouse_sink::body"
RowBinary mode: skipping unsupported payload type at offset 0
ERROR ... at offset 1, 2, ... 99
ERROR connector: connector_target="iggy_connector_clickhouse_sink::sink"
ClickHouse sink ID: 1 — no serialisable messages in batch of 100
INFO iggy_connectors::sink: Consumed 100 messages in 675µs for sink connector with ID: 1
INFO connector: ClickHouse sink ID: 1 closed. Processed 0 messages.
SELECT count() FROM default.repro returns 0.
Restarting the connector against the same consumer group redelivers nothing: no batch, no errors, still no rows. The offsets were committed at poll time, so the 100 messages cannot be recovered without resetting them by hand.
Tagging the batch from the payload's type rather than the decoder's makes the same configuration land all 100 rows with the expected values, which confirms the cause.
Iggy server config
Default server config; not involved.
Reproduction
- Run an Iggy server and a ClickHouse instance with
CREATE TABLE repro (id Int64, name String) ENGINE = MergeTree ORDER BY id.
- Produce Avro-encoded records matching the schema above to
repro/avro.
- Run
iggy-connectors with the sink config above.
- The log fills with
skipping unsupported payload type and the table stays
empty.
- Restart the connector: nothing is redelivered, and the table is still empty.
Contribution
Good first issue
Bug description
When a sink's
[[streams]]entry setsschema = "avro","proto"or"flatbuffer", the sink receives its messages as the wrongPayloadvariant.The runtime tags each batch with the decoder's schema rather than the type the decoder returned. Those three decoders all return
Payload::Jsonunder the config the runtime gives them, so the tag and the bytes disagree. The SDK rebuilds the payload from the tag, so a sink is handed aPayload::Avroholding JSON.What happens next depends on the sink. Sinks that only handle
Payload::Json— ClickHouse, Delta, Elasticsearch, Meilisearch, Quickwit, Iceberg — log the message as an unsupported payload type and drop it. Sinks that accept the raw variants — S3, HTTP, SurrealDB — base64-encode the JSON text and store it as though it were Avro bytes.Expected: the sink receives
Payload::Json, because that is what the decoder produced.Root cause
The bytes sent alongside come from
Payload::try_into_vec(), which for thePayload::Jsonthe Avro decoder returned gives JSON (core/connectors/sdk/src/lib.rs:163-165). The same tag is put onRawMessagesat:731. The SDK rebuilds from it (sdk/src/sink.rs:199, thenlib.rs:263:Schema::Avro => Payload::Avro(value)).No connector config avoids this. The runtime builds the Avro decoder with
..AvroConfig::default()(sink.rs:532-536), soextract_as_jsoncannot be turned off from a connector file, and Proto and FlatBuffer getSchema::decoder()defaults with no knob at all.Sources are unaffected: a source plugin tags its own
ProducedMessages.schemaand the encoder takes thePayloaddirectly (source.rs:430-435), with no rebuild step.Which schemas are affected
schemajson,text,rawavroPayload::Json(decoders/avro.rs:169)AvroPayload::AvroflatbufferPayload::Json(decoders/flatbuffer.rs:109)FlatBufferPayload::FlatBufferprotoPayload::Json, if the message is anAny(decoders/proto.rs:529)ProtoPayload::RawThe three that work are the ones whose decoder output matches its own
schema(). Avro and FlatBuffer default toextract_as_json: true; Proto with no schema configured falls through todecode_as_any(proto.rs:264-265), which also returns JSON. Proto ends up asPayload::Rawrather thanPayload::Protobecausetry_into_payloadre-decodes the bytes as aprost_types::Anyand falls back toRawwhen that fails (lib.rs:260).Impact
For the Json-only sinks the batch is lost without an error being raised. The ClickHouse sink ends up with an empty body and returns
Ok(())(sinks/clickhouse_sink/src/sink.rs:146-152), and offsets are committed with the poll request (AutoCommit::When(AutoCommitWhen::PollingMessages),runtime/src/sink.rs:522) — the SDK documents that mode as sending "the commit with the poll request itself, before your code sees the batch" (core/sdk/src/clients/consumer.rs:501). So the offsets advance before the sink runs, and restarting the connector does not redeliver.The batch is still counted as consumed. A run of 100 messages logs
Consumed 100 messages, and the connector reportsProcessed 0 messages.at close.Related to #3950 and PR #3954, which bind the sink's return status and let a sink defer the offset commit. Neither covers this: the ClickHouse sink returns
Ok(())on an empty body, so even withoffset_commit = after_consumingthe batch would be committed as successfully written.Suggested fix
Tag the batch with the payload's type rather than the decoder's. Changing each decoder's
schema()would not be enough on its own: transforms run after decoding and can change the type again, and the Proto decoder returns eitherJsonorRawdepending on the path it takes.Adding a
Payload::schema()and using it whereMessagesMetadataandRawMessagesare built (runtime/src/sink.rs:362and:731) covers all three formats in one place. I can send a PR either way; say which you prefer.Compatibility
The fix changes what three sinks write. S3, HTTP and SurrealDB match on
Payload::Avroand base64-encode it, so an avro stream reaches them today as base64 of JSON text, stored as though it were Avro bytes. Nothing reports this. After the fix they receivePayload::Jsonand write a JSON document, so anyone consuming their current output would see it change shape. That output is corrupt.The Json-only sinks go from dropping the batch to writing it correctly. Streams configured
json,textorraware unaffected, because the decoder's schema and the payload's type already agree and the tag does not move.Two related gaps:
StreamConsumerConfigonly exposesavro_schema_jsonandavro_schema_path, so flatbuffer and proto cannot be given a schema through the runtime at all.runtime/example_config/connectors/useschema = "json". A test across the decoders would catch this class of problem.Affected area / component
Connectors
Deployment
Compiled from source
Versions
master @
412014a—iggy-connectors0.5.0-edge.6,iggy_connector_sdk0.4.0-edge.3,iggy_connector_clickhouse_sink0.2.0-edge.4Hardware / environment
macOS arm64, local build; not hardware-dependent.
Sample code
Producer — an ordinary
IggyProducer, with each payload an Avro datum for that schema:Logs
100 Avro messages into a single-partition topic, ClickHouse sink in
row_binarymode:SELECT count() FROM default.reproreturns 0.Restarting the connector against the same consumer group redelivers nothing: no batch, no errors, still no rows. The offsets were committed at poll time, so the 100 messages cannot be recovered without resetting them by hand.
Tagging the batch from the payload's type rather than the decoder's makes the same configuration land all 100 rows with the expected values, which confirms the cause.
Iggy server config
Default server config; not involved.
Reproduction
CREATE TABLE repro (id Int64, name String) ENGINE = MergeTree ORDER BY id.repro/avro.iggy-connectorswith the sink config above.skipping unsupported payload typeand the table staysempty.
Contribution
Good first issue