This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 3bfc41809 feat(connectors): add Apache Doris sink connector (#3215)
3bfc41809 is described below
commit 3bfc41809b692d046d0353a493e8e3614ad7fef9
Author: Ryan Huang <[email protected]>
AuthorDate: Fri May 29 13:53:11 2026 +0800
feat(connectors): add Apache Doris sink connector (#3215)
---
.config/nextest.toml | 16 +
.github/actions/rust/pre-merge/action.yml | 10 +
Cargo.lock | 22 +
Cargo.toml | 7 +
core/connectors/README.md | 1 +
.../example_config/connectors/doris_sink.toml | 54 +
core/connectors/sinks/README.md | 1 +
core/connectors/sinks/doris_sink/Cargo.toml | 51 +
core/connectors/sinks/doris_sink/README.md | 99 ++
core/connectors/sinks/doris_sink/config.toml | 54 +
core/connectors/sinks/doris_sink/src/lib.rs | 1545 ++++++++++++++++++++
core/integration/Cargo.toml | 6 +-
.../tests/connectors/doris/doris_sink.rs | 492 +++++++
core/integration/tests/connectors/doris/mod.rs | 20 +
.../integration/tests/connectors/doris/sink.toml | 14 +-
.../tests/connectors/fixtures/doris/container.rs | 717 +++++++++
.../tests/connectors/fixtures/doris/mod.rs | 25 +
core/integration/tests/connectors/fixtures/mod.rs | 5 +
core/integration/tests/connectors/mod.rs | 1 +
19 files changed, 3128 insertions(+), 12 deletions(-)
diff --git a/.config/nextest.toml b/.config/nextest.toml
index fb488b694..0f4f9a2c3 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -21,6 +21,22 @@
filter = 'package(integration) and
test(cli::system::test_cli_session_scenario::should_be_successful)'
threads-required = "num-cpus"
+# Doris tests are serialized among themselves, but no longer monopolize the
+# whole runner. The all-in-one image's BE advertises 127.0.0.1:8040 for the
+# FE→BE 307 redirect, so only one Doris container per process can bind
+# host:8040. Within a nextest binary the fixture caches one shared container in
+# `SHARED_DORIS` so the first doris test pays the ~40s boot and the rest reuse
+# it; `max-threads = 1` keeps a second nextest binary (or a re-run) from
+# racing the same host port, while still letting unrelated light tests fill
+# the rest of the runner.
+[test-groups.doris]
+max-threads = 1
+
+[[profile.default.overrides]]
+filter = 'package(integration) and test(/connectors::doris::/)'
+test-group = "doris"
+slow-timeout = { period = "60s", terminate-after = 8 }
+
[profile.default]
slow-timeout = { period = "30s", terminate-after = 4 }
diff --git a/.github/actions/rust/pre-merge/action.yml
b/.github/actions/rust/pre-merge/action.yml
index 574134dea..c7388b909 100644
--- a/.github/actions/rust/pre-merge/action.yml
+++ b/.github/actions/rust/pre-merge/action.yml
@@ -207,6 +207,16 @@ runs:
source <(cargo llvm-cov show-env --export-prefix)
+ # Doris 4.0.3's start_be.sh hard-`exit 1`s unless vm.max_map_count >=
2000000.
+ # This kernel param can only be set on the host (no container can
raise it),
+ # so we raise it here. Everything else about booting Doris — image,
heap/mem
+ # caps, port mappings, BE-alive wait — lives in the testcontainers
fixture
+ # at core/integration/tests/connectors/fixtures/doris/container.rs, so
+ # `cargo test` and CI follow exactly the same path.
+ if [[ "$RUNNER_OS" == "Linux" ]]; then
+ sudo sysctl -w vm.max_map_count=2000000 || true
+ fi
+
bins_start=$(date +%s)
if [[ -n "$PACKAGE_FLAGS" ]]; then
cargo build --locked $PACKAGE_FLAGS
diff --git a/Cargo.lock b/Cargo.lock
index 97007571e..4a44aa177 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6715,6 +6715,26 @@ dependencies = [
"url",
]
+[[package]]
+name = "iggy_connector_doris_sink"
+version = "0.1.0"
+dependencies = [
+ "async-trait",
+ "base64",
+ "blake3",
+ "bytes",
+ "humantime",
+ "iggy_connector_sdk",
+ "reqwest 0.13.4",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "simd-json",
+ "tokio",
+ "tracing",
+ "wiremock",
+]
+
[[package]]
name = "iggy_connector_elasticsearch_sink"
version = "0.4.1-edge.1"
@@ -7185,6 +7205,7 @@ dependencies = [
"iggy-cli",
"iggy_binary_protocol",
"iggy_common",
+ "iggy_connector_doris_sink",
"iggy_connector_sdk",
"jsonwebtoken",
"keyring-core",
@@ -7209,6 +7230,7 @@ dependencies = [
"sysinfo 0.39.2",
"tempfile",
"test-case",
+ "testcontainers",
"testcontainers-modules",
"tokio",
"toml 1.1.2+spec-1.1.0",
diff --git a/Cargo.toml b/Cargo.toml
index 2b994b357..172193865 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,6 +34,7 @@ members = [
"core/connectors/runtime",
"core/connectors/sdk",
"core/connectors/sinks/delta_sink",
+ "core/connectors/sinks/doris_sink",
"core/connectors/sinks/elasticsearch_sink",
"core/connectors/sinks/http_sink",
"core/connectors/sinks/iceberg_sink",
@@ -278,6 +279,10 @@ sqlx = { version = "0.9.0", features = [
"runtime-tokio",
"tls-rustls",
"postgres",
+ # "mysql": Doris exposes a MySQL-wire frontend; the Doris sink's
+ # integration-test fixture talks to it over this driver. Cargo unifies
+ # features across the workspace, so it is declared once here.
+ "mysql",
"chrono",
"uuid",
"json",
@@ -290,6 +295,7 @@ sysinfo = "0.39.2"
tempfile = "3.27.0"
terminal_size = { version = "0.4.4" }
test-case = "3.3.1"
+testcontainers = { version = "0.27.3", features = ["reusable-containers"] }
testcontainers-modules = { version = "0.15.0", features = ["postgres",
"http_wait"] }
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
@@ -327,6 +333,7 @@ web-sys = { version = "0.3", features = [
] }
webpki-roots = "1.0.7"
windows-native-keyring-store = "1.1.0"
+wiremock = "0.6"
yew = { version = "0.23", features = ["csr"] }
yew-router = "0.20"
zbus-secret-service-keyring-store = { version = "1.0.0", features =
["rt-async-io-crypto-rust"] }
diff --git a/core/connectors/README.md b/core/connectors/README.md
index f955a4872..b7d24cfba 100644
--- a/core/connectors/README.md
+++ b/core/connectors/README.md
@@ -80,6 +80,7 @@ Each sink should have its own, custom configuration, which is
passed along with
### Available Sinks
+- **Doris Sink** - loads JSON messages into Apache Doris tables via the Stream
Load HTTP API
- **Elasticsearch Sink** - sends messages to Elasticsearch indices
- **Iceberg Sink** - writes data to Apache Iceberg tables via REST catalog
- **PostgreSQL Sink** - stores messages in PostgreSQL database tables
diff --git a/core/connectors/runtime/example_config/connectors/doris_sink.toml
b/core/connectors/runtime/example_config/connectors/doris_sink.toml
new file mode 100644
index 000000000..656b0c2b4
--- /dev/null
+++ b/core/connectors/runtime/example_config/connectors/doris_sink.toml
@@ -0,0 +1,54 @@
+# 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.
+
+type = "sink"
+key = "doris"
+enabled = true
+version = 0
+name = "Doris sink"
+path = "<BASE_DIR>/target/release/libiggy_connector_doris_sink"
+plugin_config_format = "toml"
+verbose = false
+
+[[streams]]
+stream = "events"
+topics = ["doris_events"]
+schema = "json"
+batch_length = 100
+poll_interval = "5ms"
+consumer_group = "doris_sink"
+
+[plugin_config]
+fe_url = "http://localhost:8030"
+database = "iggy_demo"
+table = "events"
+username = "root"
+password = "replace_with_secret"
+label_prefix = "iggy"
+batch_size = 1000
+timeout = "30s"
+# TCP connect timeout (default "5s"). Independent of timeout; raise it for
+# cross-region or cold-start FEs that are slow to accept the connection.
+# connect_timeout = "5s"
+# Stream Load redirect security. Doris's FE redirects (307) to a BE on another
+# host; credentials are re-attached across that hop. By default a redirect that
+# downgrades https -> http is refused (it would leak credentials in cleartext).
+# Set true only for a known-insecure FE -> BE topology.
+# allow_insecure_redirect = false
+# Optional allowlist of hosts a redirect may target. When set and non-empty,
+# any redirect to another host is refused (hard lockdown against a MITM'd FE).
+# allowed_redirect_hosts = ["be1.doris.internal", "be2.doris.internal"]
diff --git a/core/connectors/sinks/README.md b/core/connectors/sinks/README.md
index 77f15d8da..f97d7c6ef 100644
--- a/core/connectors/sinks/README.md
+++ b/core/connectors/sinks/README.md
@@ -8,6 +8,7 @@ Sink connectors are responsible for writing data from Iggy
streams to external s
| Sink | Description |
| ---- | ----------- |
+| **doris_sink** | Loads JSON messages into Apache Doris tables via the Stream
Load HTTP API |
| **elasticsearch_sink** | Sends messages to Elasticsearch indices for
full-text search and analytics |
| **iceberg_sink** | Writes data to Apache Iceberg tables via REST catalog
with S3/GCS/Azure storage |
| **postgres_sink** | Stores messages in PostgreSQL database tables with
configurable schemas |
diff --git a/core/connectors/sinks/doris_sink/Cargo.toml
b/core/connectors/sinks/doris_sink/Cargo.toml
new file mode 100644
index 000000000..1a5a4722e
--- /dev/null
+++ b/core/connectors/sinks/doris_sink/Cargo.toml
@@ -0,0 +1,51 @@
+# 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.
+
+[package]
+name = "iggy_connector_doris_sink"
+version = "0.1.0"
+description = "Iggy is the persistent message streaming platform written in
Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing
millions of messages per second."
+edition = "2024"
+license = "Apache-2.0"
+keywords = ["iggy", "messaging", "streaming"]
+categories = ["command-line-utilities", "database", "network-programming"]
+homepage = "https://iggy.apache.org"
+documentation = "https://iggy.apache.org/docs"
+repository = "https://github.com/apache/iggy"
+readme = "../../README.md"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "lib"]
+
+[dependencies]
+async-trait = { workspace = true }
+base64 = { workspace = true }
+blake3 = { workspace = true }
+bytes = { workspace = true }
+humantime = { workspace = true }
+iggy_connector_sdk = { workspace = true }
+reqwest = { workspace = true }
+secrecy = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+simd-json = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+wiremock = { workspace = true }
diff --git a/core/connectors/sinks/doris_sink/README.md
b/core/connectors/sinks/doris_sink/README.md
new file mode 100644
index 000000000..6da9937be
--- /dev/null
+++ b/core/connectors/sinks/doris_sink/README.md
@@ -0,0 +1,99 @@
+# Apache Doris Sink
+
+The Doris sink connector consumes JSON messages from Iggy streams and writes
them to a pre-created Apache Doris table via Doris's [Stream Load HTTP
API](https://doris.apache.org/docs/data-operate/import/import-way/stream-load-manual).
+
+## Requirements
+
+- The target Doris **database and table must be pre-created** before enabling
the sink. The connector never issues DDL.
+- `database` and `table` config values must match `[A-Za-z0-9_]+`. Anything
else is rejected at startup with `Error::InvalidConfigValue` — this also
prevents path traversal in the constructed `/api/{db}/{table}/_stream_load` URL.
+- Messages must arrive with `Payload::Json` (i.e. the configured stream schema
is `json`). If a non-JSON payload reaches the connector it logs at `error!` and
aborts the whole poll; since the consumer offset is already committed at poll
time the batch is not replayed — effectively silent data loss — so the upstream
schema must be guaranteed JSON. (Under `schema = "json"` the SDK drops non-JSON
before the connector sees it, so this abort is a defensive guard.)
+- The Iggy message JSON shape must match the target table columns. Use the
optional `columns` plugin setting if the column order differs from the JSON
keys.
+
+## How it works
+
+1. For each batch of messages, the connector serializes the JSON payloads into
a JSON array.
+2. It computes a deterministic Stream Load `label` of the form
`{label_prefix}-{stream_san}-{topic_san}-{hash16}-{partition}-{first_offset}-{last_offset}`.
+ - `hash16` is a single 64-bit blake3 hash computed over the *raw*
(un-sanitized), length-prefixed `(label_prefix, stream, topic)` triple. So
identities that sanitize to the same string get distinct labels — whether the
collision is in the names (`events.v1` vs `events_v1`) or in two tenants'
prefixes that truncate alike (`prod_events_us_east_1` vs `..._2`) — and no
boundary-shift aliasing is possible (`("ab","c")` ≠ `("a","bc")`).
+ - The total label is bounded under Doris's 128-char cap regardless of input
length (worst case 120 chars).
+ - Doris dedupes loads by label inside its `label_keep_max_second` window.
The deterministic label is **forward-compatible scaffolding**: if the runtime
ever gains retry/redrive, a duplicate load would be absorbed, not doubled.
**Today it protects no production scenario** — there is no retry loop,
`consume()` runs once per poll, and the runtime discards its return value.
Delivery is at-most-once: the offset is committed before `consume()` runs, so a
failed load is never replayed.
+3. It `PUT`s the batch to `{fe_url}/api/{database}/{table}/_stream_load` with
HTTP Basic auth and the headers `Expect: 100-continue`, `format: json`,
`strip_outer_array: true`, `label: <label>`. (`Expect: 100-continue` is
required by Doris's Stream Load endpoint, which rejects PUTs that omit it.
Where the HTTP stack negotiates the handshake it also lets Doris reject
auth/4xx before the body uploads — a secondary benefit, not relied on for
correctness.)
+4. The Doris frontend (FE) responds with a `307 Temporary Redirect` to a
backend (BE). The connector follows the redirect manually so that the
`Authorization` header is preserved across the hop (`reqwest`'s default policy
strips it on cross-host redirects).
+ `308 Permanent Redirect` is also followed as a defensive measure; redirects
beyond a hard cap of 5 (or a redirect with no usable `Location`) are rejected
as a permanent `PermanentHttpError`, since retrying a malformed/looping
redirect cannot help.
+5. The HTTP body is parsed as JSON and the `Status` field decides the outcome:
+ - `Success` → batch accepted.
+ - `Label Already Exists` → idempotent replay, treated as success.
+ - `Publish Timeout` or HTTP `5xx`/`408`/`429` → classified as a transient
error (`Error::CannotStoreData`) — retryable in principle, but per the
at-most-once note above the runtime does not currently act on it.
+ - `Fail`, any other `4xx`, or an unparsable response body → permanent error
(`Error::PermanentHttpError`); retrying would not help even if the runtime did
redrive.
+
+## Configuration
+
+| Field | Required | Default | Description |
+| --- | --- | --- | --- |
+| `fe_url` | yes | — | Doris frontend HTTP base URL, e.g.
`http://localhost:8030`. |
+| `database` | yes | — | Target database. Must match `[A-Za-z0-9_]+`. |
+| `table` | yes | — | Target table. Must match `[A-Za-z0-9_]+`. |
+| `username` | yes | — | Doris user with `LOAD_PRIV` on the table. |
+| `password` | yes | — | Doris user password. Stored as a
`secrecy::SecretString` and never logged. |
+| `label_prefix` | no | `iggy` | Prefix for the deterministic Stream Load
label. |
+| `batch_size` | no | `1000` | Maximum number of messages per Stream Load
request. |
+| `timeout` | no | `30s` | Per-request HTTP timeout (total request budget), as
a human-readable duration (e.g. `30s`, `1m`). |
+| `connect_timeout` | no | `5s` | TCP connect timeout, independent of
`timeout`, as a human-readable duration. Raise it for cross-region or
cold-start FEs. |
+| `max_filter_ratio` | no | unset | Forwarded as the `max_filter_ratio` Stream
Load header. Must be a finite value in `[0.0, 1.0]`; an out-of-range value
fails `open()`. |
+| `columns` | no | unset | Forwarded as the `columns` Stream Load header.
Validated at startup; an invalid value fails `open()`. |
+| `where` | no | unset | Forwarded as the `where` Stream Load header.
Validated at startup; an invalid value fails `open()`. |
+| `allow_insecure_redirect` | no | `false` | Permit a Stream Load redirect
that downgrades `https://` → `http://`. Refused by default because it would
push credentials onto a cleartext hop. |
+| `allowed_redirect_hosts` | no | unset | Allowlist of redirect targets. Each
entry is `host` (pins the host, any port) or `host:port` (pins the exact
endpoint). When set and non-empty, any other redirect target is refused. |
+
+### Example
+
+```toml
+type = "sink"
+key = "doris"
+enabled = true
+version = 0
+name = "Doris sink"
+path = "target/release/libiggy_connector_doris_sink"
+plugin_config_format = "toml"
+
+[[streams]]
+stream = "events"
+topics = ["doris_events"]
+schema = "json"
+batch_length = 100
+poll_interval = "5ms"
+consumer_group = "doris_sink"
+
+[plugin_config]
+fe_url = "http://localhost:8030"
+database = "iggy_demo"
+table = "events"
+username = "root"
+password = "replace_with_secret"
+label_prefix = "iggy"
+batch_size = 1000
+timeout = "30s"
+```
+
+## Security notes
+
+- **Use `https://` in production.** The connector accepts `http://` URLs and
logs a `warn!` when `fe_url` points at a non-loopback host over plain HTTP, but
it does not refuse. Over `http://`, the HTTP Basic credentials travel in
cleartext.
+- **Trust boundary on the FE.** The connector intentionally preserves the
`Authorization` header across the FE → BE 307 redirect (reqwest would otherwise
strip it on cross-host redirects).
+ A compromised or MITM'd FE could try to exfiltrate credentials by responding
with `Location: http://attacker/`. Before re-attaching credentials, the
connector validates the redirect target: it **refuses a scheme downgrade**
(`https://` → `http://`) unless `allow_insecure_redirect = true`, requires an
**absolute** `Location` (a relative one is rejected, not silently resolved),
and — if `allowed_redirect_hosts` is set — refuses any target outside that
allowlist.
+ **When `allowed_redirect_hosts` is unset (the default), any same-scheme host
is accepted** — that is the price of supporting the normal cross-host FE → BE
topology out of the box. For lockdown in hostile networks, set
`allowed_redirect_hosts` to your known BE endpoints and deploy Doris over TLS.
List a bare `host` to pin only the host, or `host:port` to pin the exact
endpoint — pinning the port closes the "allowlisted host, attacker port" vector.
+- **`columns` and `where` are SQL-expression pass-throughs.** Whatever you put
in those config fields is forwarded verbatim to Doris's Stream Load and
evaluated as a SQL expression. Keep this config trusted.
+
+## Operational guidance
+
+- **`label_keep_max_second`.** Idempotent replay relies on Doris retaining
each label for at least as long as it could take the Iggy runtime to redrive a
failed batch. The Doris default is 3 days, which is conservative. If you set
this lower on the Doris side, make sure your runtime retry budget fits inside
the window — once a label expires, a replay re-loads instead of deduping,
producing duplicate rows.
+- **Keep `batch_size` stable across a redrive.** The label includes the
chunk's `first_offset` and `last_offset`, which are a function of `batch_size`.
If you change `batch_size` between a failed load and its redrive, the chunk
boundaries shift, the offsets differ, and the new label no longer matches the
old one — so Doris re-loads instead of deduping, producing duplicate rows.
+- **Filtered-row alerts.** When Doris reports `number_filtered_rows > 0`, the
connector emits a `warn!`. This is your signal that upstream message shapes
have drifted from the table schema; alert on it.
+- **Multi-chunk batches are best-effort for operational failures.** A poll
larger than `batch_size` is split into chunks, each loaded as its own labelled
Stream Load. If a chunk fails *operationally* (serialize, HTTP, or
status-classification error), the connector still attempts the remaining chunks
and then returns the worst error — it does **not** stop at the first such
failure.
+ The runtime commits the consumer offset for the whole poll before
`consume()` runs, so a failed chunk is not replayed regardless; pushing the
other chunks through maximizes delivered data, and the worst error is surfaced
at the end (logged at `error!` for observability — the runtime currently
discards `consume()`'s return value, so there is no retry or DLQ).
+ The one deliberate exception is a **non-JSON payload**, which is treated as
a schema-contract violation and aborts the whole poll immediately (see the
Requirements note above). Under `schema = "json"` this is unreachable, so it is
a defensive guard rather than a normal path.
+
+## Limitations
+
+- JSON payload only. CSV and raw-text payloads are not supported yet.
+- HTTP Basic auth only.
+- No automatic table creation.
+- No built-in retry middleware or circuit breaker — the runtime decides
whether to redrive a failing batch. A hardening pass with
`iggy_connector_sdk::retry::*` is planned as a follow-up.
diff --git a/core/connectors/sinks/doris_sink/config.toml
b/core/connectors/sinks/doris_sink/config.toml
new file mode 100644
index 000000000..5aaf6b07d
--- /dev/null
+++ b/core/connectors/sinks/doris_sink/config.toml
@@ -0,0 +1,54 @@
+# 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.
+
+type = "sink"
+key = "doris"
+enabled = true
+version = 0
+name = "Doris sink"
+path = "../../target/release/libiggy_connector_doris_sink"
+plugin_config_format = "toml"
+verbose = false
+
+[[streams]]
+stream = ""
+topics = []
+schema = "json"
+batch_length = 100
+poll_interval = "5ms"
+consumer_group = "doris_sink"
+
+[plugin_config]
+fe_url = "http://localhost:8030"
+database = ""
+table = ""
+username = "root"
+password = ""
+label_prefix = "iggy"
+batch_size = 1000
+timeout = "30s"
+# TCP connect timeout (default "5s"). Independent of timeout; raise it for
+# cross-region or cold-start FEs that are slow to accept the connection.
+# connect_timeout = "5s"
+# Stream Load redirect security. Doris's FE redirects (307) to a BE on another
+# host; credentials are re-attached across that hop. By default a redirect that
+# downgrades https -> http is refused (it would leak credentials in cleartext).
+# Set true only for a known-insecure FE -> BE topology.
+# allow_insecure_redirect = false
+# Optional allowlist of hosts a redirect may target. When set and non-empty,
+# any redirect to another host is refused (hard lockdown against a MITM'd FE).
+# allowed_redirect_hosts = ["be1.doris.internal", "be2.doris.internal"]
diff --git a/core/connectors/sinks/doris_sink/src/lib.rs
b/core/connectors/sinks/doris_sink/src/lib.rs
new file mode 100644
index 000000000..a2c8c2bda
--- /dev/null
+++ b/core/connectors/sinks/doris_sink/src/lib.rs
@@ -0,0 +1,1545 @@
+/* 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 async_trait::async_trait;
+use base64::{Engine as _, engine::general_purpose};
+use bytes::Bytes;
+use humantime::Duration as HumanDuration;
+use iggy_connector_sdk::{
+ ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata,
sink_connector,
+};
+use reqwest::{Method, StatusCode, header};
+use secrecy::zeroize::Zeroizing;
+use secrecy::{ExposeSecret, SecretString};
+use serde::Deserialize;
+use std::str::FromStr;
+use std::time::Duration;
+use tracing::{debug, error, info, warn};
+
+sink_connector!(DorisSink);
+
+const DEFAULT_LABEL_PREFIX: &str = "iggy";
+const DEFAULT_BATCH_SIZE: u32 = 1000;
+// Total per-request budget. Human-readable (e.g. "30s") to match the sibling
+// web sinks (http_sink, influxdb_sink).
+const DEFAULT_TIMEOUT: &str = "30s";
+// Bounded TCP handshake timeout so an unreachable FE fails fast instead of
+// burning the whole request-timeout budget on connect alone.
+const DEFAULT_CONNECT_TIMEOUT: &str = "5s";
+// Doris's FE Stream Load 307s to a BE, and reqwest strips Authorization on
+// cross-host redirects, so we follow them manually. This caps a looping
cluster.
+const MAX_REDIRECTS: u8 = 5;
+// Doris Stream Load labels must be 1..=128 chars of `[A-Za-z0-9_-]`. These
caps
+// keep the worst-case label well under that limit.
+const MAX_LABEL_PREFIX_LEN: usize = 16;
+const MAX_LABEL_NAME_LEN: usize = 16;
+// A single 64-bit (16-hex) joint hash over the raw (prefix, stream, topic)
+// triple. 64 bits keeps the adversarial birthday bound high enough that a
+// multi-tenant namer can't cheaply force the label collisions that Doris's
+// server-side dedupe would turn into silent data loss. One joint hash (not one
+// per segment) buys that for the same length budget, leaving the sanitized
names
+// full-length.
+const LABEL_HASH_HEX_LEN: usize = 16;
+// Cap the response-body slice kept for logs/errors so a misbehaving proxy that
+// returns a giant body can't flood the logs. Bounds only what we *log*, not
peak
+// memory — `response.text()` already buffers the full body first.
+const MAX_RESPONSE_LOG_BYTES: usize = 4096;
+
+#[derive(Debug)]
+pub struct DorisSink {
+ id: u32,
+ config: DorisSinkConfig,
+ // Precomputed in `new()` and marked sensitive so reqwest keeps it out of
any
+ // debug/trace output (and never HPACK-indexes it on HTTP/2).
+ auth_header: header::HeaderValue,
+ // Set in `open()`, `None` until then. Holds the HTTP client, the parsed
+ // Stream Load URL (which doubles as the redirect-validation baseline), the
+ // precomputed/validated optional headers, and the resolved redirect
policy.
+ connected: Option<Connected>,
+}
+
+#[derive(Debug)]
+struct Connected {
+ client: reqwest::Client,
+ base_url: reqwest::Url,
+ // Optional Stream Load headers, validated once at `open()` so a bad byte
+ // fails fast at startup rather than on every batch.
+ max_filter_ratio_header: Option<header::HeaderValue>,
+ columns_header: Option<header::HeaderValue>,
+ where_header: Option<header::HeaderValue>,
+ // Redirect policy resolved once at `open()` so `validate_redirect` reads
it
+ // off `self` instead of threading it through every call.
+ allow_insecure_redirect: bool,
+ allowed_redirect_hosts: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct DorisSinkConfig {
+ pub fe_url: String,
+ pub database: String,
+ pub table: String,
+ pub username: String,
+ pub password: SecretString,
+ pub label_prefix: Option<String>,
+ pub max_filter_ratio: Option<f64>,
+ pub columns: Option<String>,
+ #[serde(rename = "where")]
+ pub where_clause: Option<String>,
+ /// Total per-request HTTP timeout as a human-readable duration, e.g. "30s"
+ /// (default 30s). Matches the `timeout` field on the http/influxdb sinks.
+ pub timeout: Option<String>,
+ /// TCP connect timeout as a human-readable duration, e.g. "5s".
Independent
+ /// of `timeout` (the total request budget). Defaults to 5s; raise it for
+ /// cross-region or cold-start FEs that are slow to accept the connection.
+ pub connect_timeout: Option<String>,
+ pub batch_size: Option<u32>,
+ /// Permit a redirect that downgrades the scheme (e.g. `https://` FE ->
+ /// `http://` BE). Off by default: a downgrade would push Basic-auth
+ /// credentials onto a cleartext hop, so we refuse it unless the operator
+ /// explicitly opts in for a known-insecure FE -> BE topology.
+ pub allow_insecure_redirect: Option<bool>,
+ /// Optional allowlist of hosts a Stream Load redirect may target. Each
entry
+ /// is `host` or `host:port`; a bare host pins only the host (any port),
while
+ /// `host:port` pins the exact endpoint. When set and non-empty, a
redirect to
+ /// any other target is refused — a hard lockdown against a
compromised/MITM'd
+ /// FE exfiltrating credentials via `Location`. When unset, cross-host
+ /// redirects are allowed (required for the normal FE -> BE topology)
subject
+ /// only to the scheme-downgrade rule above.
+ pub allowed_redirect_hosts: Option<Vec<String>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct StreamLoadResponse {
+ #[serde(rename = "Status")]
+ status: String,
+ #[serde(rename = "Message")]
+ #[serde(default)]
+ message: String,
+ #[serde(rename = "NumberLoadedRows")]
+ #[serde(default)]
+ number_loaded_rows: u64,
+ #[serde(rename = "NumberFilteredRows")]
+ #[serde(default)]
+ number_filtered_rows: u64,
+}
+
+impl DorisSink {
+ pub fn new(id: u32, config: DorisSinkConfig) -> Self {
+ let credential = Zeroizing::new(format!(
+ "{}:{}",
+ config.username,
+ config.password.expose_secret()
+ ));
+ let encoded =
Zeroizing::new(general_purpose::STANDARD.encode(credential.as_bytes()));
+ // `Basic <base64>` is always visible ASCII, so this conversion cannot
fail.
+ let auth_value = Zeroizing::new(format!("Basic {}", encoded.as_str()));
+ let mut auth_header = header::HeaderValue::from_str(&auth_value)
+ .expect("Basic auth header is always valid ASCII");
+ auth_header.set_sensitive(true);
+
+ DorisSink {
+ id,
+ config,
+ auth_header,
+ connected: None,
+ }
+ }
+
+ fn build_client(&self) -> Result<reqwest::Client, Error> {
+ let timeout = parse_duration(self.config.timeout.as_deref(),
DEFAULT_TIMEOUT);
+ let connect_timeout = parse_duration(
+ self.config.connect_timeout.as_deref(),
+ DEFAULT_CONNECT_TIMEOUT,
+ );
+ // `Policy::none()` so we follow redirects manually and keep
Authorization
+ // alive across the FE -> BE hop (reqwest strips it on cross-host
307s).
+ reqwest::Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .timeout(timeout)
+ .connect_timeout(connect_timeout)
+ .build()
+ .map_err(|e| Error::InitError(format!("Failed to build Doris HTTP
client: {e}")))
+ }
+
+ async fn send_stream_load(
+ &self,
+ label: &str,
+ body: Bytes,
+ ) -> Result<StreamLoadResponse, Error> {
+ let connected = self.connected.as_ref().ok_or_else(|| {
+ Error::InitError(format!(
+ "Doris sink ID {} called before open() — not connected",
+ self.id
+ ))
+ })?;
+ // `base_url` is the redirect-validation baseline (original FE
scheme/host)
+ // and the parsed first-hop target.
+ let mut url = connected.base_url.clone();
+ let mut redirects = 0u8;
+
+ loop {
+ let mut request = connected
+ .client
+ .request(Method::PUT, url.clone())
+ .header(header::AUTHORIZATION, self.auth_header.clone())
+ .header(header::EXPECT, "100-continue")
+ .header("format", "json")
+ .header("strip_outer_array", "true")
+ .header("label", label)
+ .body(body.clone());
+
+ // Headers were validated and built once in `open()`.
+ if let Some(value) = &connected.max_filter_ratio_header {
+ request = request.header("max_filter_ratio", value.clone());
+ }
+ if let Some(value) = &connected.columns_header {
+ request = request.header("columns", value.clone());
+ }
+ if let Some(value) = &connected.where_header {
+ request = request.header("where", value.clone());
+ }
+
+ let response = request.send().await.map_err(|e| {
+ error!("Doris sink ID {} HTTP request failed: {e}", self.id);
+ Error::HttpRequestFailed(e.to_string())
+ })?;
+
+ let status = response.status();
+ if matches!(
+ status,
+ StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT
+ ) {
+ redirects += 1;
+ if redirects > MAX_REDIRECTS {
+ // A redirect loop is permanent, not transient: retrying
just
+ // re-walks the same loop, so surface it as such.
+ return Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {} exceeded max redirects
({MAX_REDIRECTS})",
+ self.id
+ )));
+ }
+ let Some(location) = response
+ .headers()
+ .get(header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ else {
+ // A redirect with no usable Location is malformed;
retrying
+ // won't produce one.
+ return Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {} got {status} with no Location
header",
+ self.id
+ )));
+ };
+ // Doris always emits an *absolute* Location (the BE endpoint).
+ // A relative one is outside that contract; resolving it
against
+ // the current URL would silently target a sibling path (a
near-
+ // certain 404) and give a false sense of safety, so reject it.
+ let target = reqwest::Url::parse(location).map_err(|e| {
+ Error::PermanentHttpError(format!(
+ "Doris sink ID {} got {status} with non-absolute or
unparsable Location '{location}': {e}",
+ self.id
+ ))
+ })?;
+ connected.validate_redirect(&target, self.id)?;
+ debug!("Doris sink ID {} following redirect to {target}",
self.id);
+ url = target;
+ continue;
+ }
+
+ let is_success = status.is_success();
+ let response_text = match response.text().await {
+ Ok(text) => text,
+ Err(e) if is_success => {
+ // 2xx but the body never fully arrived (mid-stream TCP
reset,
+ // decompression error, body-read timeout). Doris almost
+ // certainly persisted the load, but we can't read the row
+ // counts to confirm. Classify transient — not a fabricated
+ // parse failure — so a retry re-PUTs under the same label
and
+ // Doris's dedupe reveals the real outcome instead of
DLQing a
+ // success.
+ warn!(
+ "Doris sink ID {} failed to read 2xx response body:
{e}; treating as retryable",
+ self.id
+ );
+ return Err(Error::CannotStoreData(format!(
+ "Doris sink ID {} could not read 2xx Stream Load
response body: {e}",
+ self.id
+ )));
+ }
+ Err(e) => {
+ // Non-2xx with an unreadable body: log it, then fall back
to
+ // an empty body so the status-based handling below still
+ // classifies the outcome (empty body on a non-2xx =>
permanent).
+ warn!(
+ "Doris sink ID {} failed to read response body: {e}",
+ self.id
+ );
+ String::new()
+ }
+ };
+ let response_for_log = truncate_for_log(&response_text,
MAX_RESPONSE_LOG_BYTES);
+
+ if !is_success {
+ let msg = format!(
+ "Doris sink ID {} stream load returned HTTP {status}:
{response_for_log}",
+ self.id
+ );
+ error!("{msg}");
+ // 408/429 are 4xx but transient — retry them, don't DLQ.
+ return Err(match status {
+ StatusCode::REQUEST_TIMEOUT |
StatusCode::TOO_MANY_REQUESTS => {
+ Error::CannotStoreData(msg)
+ }
+ s if s.is_server_error() => Error::CannotStoreData(msg),
+ _ => Error::PermanentHttpError(msg),
+ });
+ }
+
+ return parse_stream_load_response(&response_text);
+ }
+ }
+}
+
+impl Connected {
+ /// Validate a Stream Load redirect target before re-attaching credentials.
+ ///
+ /// Doris's FE legitimately redirects (307) to a BE on a *different host*,
so
+ /// we can't require same-host. Instead we enforce three rules that close
the
+ /// credential-exfiltration vector a compromised/MITM'd FE would otherwise
have:
+ ///
+ /// 0. The target scheme must be `http` or `https`. A non-HTTP scheme
+ /// (`ftp`, `file`, ...) would slip past the downgrade rule when the
FE
+ /// itself is `http`, so reject it before re-attaching credentials.
+ /// 1. No scheme downgrade (`https` -> `http`) unless
`allow_insecure_redirect`
+ /// is set — a downgrade would push Basic-auth creds onto a cleartext
hop.
+ /// 2. If `allowed_redirect_hosts` is non-empty, the target must match an
+ /// entry. A bare-host entry pins only the host; a `host:port` entry
pins
+ /// the exact endpoint, refusing an allowlisted host on an attacker
port.
+ fn validate_redirect(&self, target: &reqwest::Url, id: u32) -> Result<(),
Error> {
+ // Only http(s) targets ever get credentials re-attached. Preventing
something like ftp://.
+ let scheme = target.scheme();
+ if !scheme.eq_ignore_ascii_case("http") &&
!scheme.eq_ignore_ascii_case("https") {
+ return Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {id}: refusing redirect to non-HTTP(S) scheme
'{scheme}'"
+ )));
+ }
+
+ let downgraded = self.base_url.scheme().eq_ignore_ascii_case("https")
+ && !target.scheme().eq_ignore_ascii_case("https");
+ if downgraded && !self.allow_insecure_redirect {
+ return Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {id}: refusing redirect that downgrades {} ->
{} \
+ (would leak credentials in cleartext; set
allow_insecure_redirect=true \
+ to permit a known-insecure FE -> BE topology)",
+ self.base_url.scheme(),
+ target.scheme(),
+ )));
+ }
+
+ if let Some(allowed) = self.allowed_redirect_hosts.as_deref()
+ && !allowed.is_empty()
+ && !redirect_target_allowed(allowed, target)
+ {
+ return Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {id}: redirect target '{}:{}' is not in
allowed_redirect_hosts",
+ target.host_str().unwrap_or(""),
+ target
+ .port_or_known_default()
+ .map(|p| p.to_string())
+ .unwrap_or_default(),
+ )));
+ }
+
+ Ok(())
+ }
+}
+
+/// Match a redirect target against the allowlist. An entry of `host` matches
any
+/// port on that host; an entry of `host:port` pins the exact endpoint. DNS
names,
+/// IPv4 literals, and IPv6 literals (bare `::1` or bracketed
`[::1]`/`[::1]:8040`)
+/// all split cleanly.
+fn redirect_target_allowed(allowed: &[String], target: &reqwest::Url) -> bool {
+ let raw_host = target.host_str().unwrap_or("");
+ // `host_str()` brackets IPv6 literals (`[::1]`); strip them so a bare
(`::1`)
+ // or bracketed (`[::1]`) allowlist entry both compare equal.
+ let host = strip_brackets(raw_host);
+ let port = target.port_or_known_default();
+ allowed.iter().any(|entry| match split_host_port(entry) {
+ (entry_host, Some(entry_port)) => {
+ entry_host.eq_ignore_ascii_case(host) && Some(entry_port) == port
+ }
+ (entry_host, None) => entry_host.eq_ignore_ascii_case(host),
+ })
+}
+
+/// Strip a single pair of surrounding `[ ]` brackets from an IPv6 literal, so
a
+/// bracketed host compares equal to its bare form.
+fn strip_brackets(host: &str) -> &str {
+ host.strip_prefix('[')
+ .and_then(|h| h.strip_suffix(']'))
+ .unwrap_or(host)
+}
+
+/// Split an allowlist entry into `(host, optional port)`, with the host
returned
+/// *unbracketed* so it compares against a bracket-stripped `host_str()`.
+///
+/// - `[host]` / `[host]:port` — bracketed IPv6: the bracketed host splits
from an
+/// optional trailing `:<port>`.
+/// - A bare entry with more than one `:` is an unbracketed IPv6 literal
(`::1`,
+/// `fe80::1`); a port suffix on it would be ambiguous, so it is host-only.
Pin a
+/// port on an IPv6 host by bracketing it (`[::1]:8040`).
+/// - Otherwise a trailing `:<digits>` is the port; anything else is host-only.
+fn split_host_port(entry: &str) -> (&str, Option<u16>) {
+ if let Some(rest) = entry.strip_prefix('[') {
+ // Bracketed IPv6: `[host]` or `[host]:port`.
+ if let Some((host, after)) = rest.split_once(']') {
+ let port = after.strip_prefix(':').and_then(|p|
p.parse::<u16>().ok());
+ return (host, port);
+ }
+ // No closing `]`: malformed, treat the whole thing as host-only.
+ return (entry, None);
+ }
+ // A bare multi-colon entry is an unbracketed IPv6 literal: host-only.
+ if entry.matches(':').count() > 1 {
+ return (entry, None);
+ }
+ if let Some((host, port)) = entry.rsplit_once(':')
+ && !port.is_empty()
+ && let Ok(port) = port.parse::<u16>()
+ {
+ (host, Some(port))
+ } else {
+ (entry, None)
+ }
+}
+
+/// Parse a human-readable duration (e.g. "30s"), falling back to `default`
with
+/// a warning on a malformed *or zero* value. Mirrors the http/influxdb sinks.
+///
+/// A zero duration parses fine but is degenerate: reqwest treats a zero
+/// timeout/connect-timeout as an immediate deadline, so every request fails
with
+/// a `TimedOut` error before it can complete. Treat it like a malformed value.
+fn parse_duration(input: Option<&str>, default: &str) -> Duration {
+ let raw = input.unwrap_or(default);
+ let fallback = || *HumanDuration::from_str(default).expect("default
duration must be valid");
+ let parsed = HumanDuration::from_str(raw)
+ .map(|d| *d)
+ .unwrap_or_else(|e| {
+ warn!("Invalid duration '{raw}': {e}, using default '{default}'");
+ fallback()
+ });
+ if parsed.is_zero() {
+ warn!(
+ "Duration '{raw}' is zero, which would time out every request
immediately; \
+ using default '{default}'"
+ );
+ return fallback();
+ }
+ parsed
+}
+
+/// Build the Stream Load URL from `fe_url` and the (already
identifier-checked)
+/// `database`/`table`. Replaces the path wholesale so a trailing slash or
stray
+/// path on `fe_url` can't double up the path.
+fn build_stream_load_url(
+ id: u32,
+ fe_url: &str,
+ database: &str,
+ table: &str,
+) -> Result<reqwest::Url, Error> {
+ let mut url = reqwest::Url::parse(fe_url).map_err(|e| {
+ Error::InvalidConfigValue(format!(
+ "Doris sink ID {id} has invalid fe_url '{fe_url}': {e}"
+ ))
+ })?;
+ // Stream Load speaks HTTP. A `file://`/`ftp://`/... base parses fine but
would
+ // only fail later, per-batch, at `send()`; reject it here at startup
instead.
+ let scheme = url.scheme();
+ if scheme != "http" && scheme != "https" {
+ return Err(Error::InvalidConfigValue(format!(
+ "Doris sink ID {id} fe_url '{fe_url}' must use http or https, got
'{scheme}'"
+ )));
+ }
+ url.set_path(&format!("/api/{database}/{table}/_stream_load"));
+ Ok(url)
+}
+
+/// Effective batch size: the configured value floored at 1 so `chunks()` is
+/// never handed a 0 (which would panic).
+fn effective_batch_size(configured: Option<u32>) -> usize {
+ configured.unwrap_or(DEFAULT_BATCH_SIZE).max(1) as usize
+}
+
+/// Build a validated Stream Load header value, surfacing a bad byte (CR/LF,
+/// non-visible-ASCII) as a startup-time `InvalidConfigValue` instead of a
+/// per-batch `HttpRequestFailed` (reqwest defers `HeaderValue::try_from` to
+/// `.send()`, so an invalid `columns`/`where` would otherwise fail every
batch).
+fn validated_header(field: &str, value: &str, id: u32) ->
Result<header::HeaderValue, Error> {
+ header::HeaderValue::from_str(value).map_err(|e| {
+ Error::InvalidConfigValue(format!(
+ "Doris sink ID {id}: '{field}' header value is invalid (must be
visible ASCII, no CR/LF): {e}"
+ ))
+ })
+}
+
+/// Replace Doris-label-illegal characters with `_` and cap the result at
+/// `max_len` chars. Doris labels allow `[A-Za-z0-9_-]` only.
+fn sanitize_segment(value: &str, max_len: usize) -> String {
+ value
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .take(max_len)
+ .collect()
+}
+
+/// A single blake3 fingerprint over the *raw* (unsanitized) `prefix`,
`stream`,
+/// and `topic`, truncated to `LABEL_HASH_HEX_LEN` hex chars. This
disambiguates
+/// identities that sanitize+truncate to the same string (e.g. `events.v1` vs
+/// `events_v1`, or prefixes `prod_events_us_east_1` vs `..._2`), which would
+/// otherwise produce identical labels and cause silent data loss via Doris's
+/// server-side label dedupe. Inputs are length-prefixed so distinct triples
+/// can't alias into one digest (e.g. `("ab","c",..)` vs `("a","bc",..)`).
+fn identity_hash(prefix: &str, stream: &str, topic: &str) -> String {
+ let mut hasher = blake3::Hasher::new();
+ for part in [prefix, stream, topic] {
+ hasher.update(&(part.len() as u64).to_le_bytes());
+ hasher.update(part.as_bytes());
+ }
+ let hash = hasher.finalize().to_hex();
+ hash.as_str()[..LABEL_HASH_HEX_LEN].to_string()
+}
+
+/// Pure label builder. Format:
+///
`{prefix_san}-{stream_san}-{topic_san}-{hash16}-{partition}-{first}-{last}`.
+///
+/// The segment caps bound the total under Doris's 128-char label limit (worst
+/// case 120), and the joint `hash16` over the raw (prefix, stream, topic)
keeps
+/// labels distinct even when the sanitized segments collide.
+///
+/// `#[doc(hidden)]`: `pub` only so the integration test harness can reproduce
+/// labels; not part of the connector's supported API.
+#[doc(hidden)]
+pub fn build_label(
+ prefix: &str,
+ stream: &str,
+ topic: &str,
+ partition_id: u32,
+ first_offset: u64,
+ last_offset: u64,
+) -> String {
+ format!(
+ "{}-{}-{}-{}-{}-{}-{}",
+ sanitize_segment(prefix, MAX_LABEL_PREFIX_LEN),
+ sanitize_segment(stream, MAX_LABEL_NAME_LEN),
+ sanitize_segment(topic, MAX_LABEL_NAME_LEN),
+ identity_hash(prefix, stream, topic),
+ partition_id,
+ first_offset,
+ last_offset,
+ )
+}
+
+/// Truncate `s` at the largest char boundary `<= max_bytes` and append a
marker
+/// recording the original size. Bounds the portion of an HTTP response body
that
+/// lands in logs or error variants.
+fn truncate_for_log(s: &str, max_bytes: usize) -> String {
+ if s.len() <= max_bytes {
+ return s.to_string();
+ }
+ let mut end = max_bytes;
+ while end > 0 && !s.is_char_boundary(end) {
+ end -= 1;
+ }
+ format!("{}...(truncated, total {} bytes)", &s[..end], s.len())
+}
+
+fn parse_stream_load_response(body: &str) -> Result<StreamLoadResponse, Error>
{
+ // An unparsable 200-OK body (Doris bug, proxy-injected HTML, future schema
+ // change) isn't cured by retrying the same bytes — default to permanent so
+ // the runtime DLQs the batch instead of looping.
+ serde_json::from_str(body).map_err(|e| {
+ Error::PermanentHttpError(format!(
+ "Failed to parse Doris stream load response: {e}. Body: {}",
+ truncate_for_log(body, MAX_RESPONSE_LOG_BYTES)
+ ))
+ })
+}
+
+fn validate_identifier(name: &str, field: &str, id: u32) -> Result<(), Error> {
+ if name.is_empty() {
+ return Err(Error::InvalidConfigValue(format!(
+ "Doris sink ID {id}: {field} must not be empty"
+ )));
+ }
+ if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
+ return Err(Error::InvalidConfigValue(format!(
+ "Doris sink ID {id}: {field} '{name}' must match [A-Za-z0-9_]+
(iggy's stricter subset of Doris identifiers, used as a path-traversal guard)"
+ )));
+ }
+ Ok(())
+}
+
+fn classify_status(response: &StreamLoadResponse) -> Result<(), Error> {
+ match response.status.as_str() {
+ "Success" => Ok(()),
+ "Label Already Exists" => {
+ // Idempotent replay — the data was already loaded with this label.
+ // Treat as success so the runtime advances the consumer offset.
+ info!(
+ "Doris reported 'Label Already Exists' (loaded={},
filtered={}); treating as success.",
+ response.number_loaded_rows, response.number_filtered_rows
+ );
+ Ok(())
+ }
+ "Publish Timeout" => Err(Error::CannotStoreData(format!(
+ "Doris stream load Publish Timeout: {}",
+ response.message
+ ))),
+ "Fail" => Err(Error::PermanentHttpError(format!(
+ "Doris stream load failed: {}",
+ response.message
+ ))),
+ // Default unknown statuses to permanent: surfacing an unrecognized
+ // failure (e.g. a future Doris error variant) and letting the runtime
DLQ
+ // it beats silently retrying it against the FE forever.
+ other => Err(Error::PermanentHttpError(format!(
+ "Doris stream load returned unexpected status '{other}': {}",
+ response.message
+ ))),
+ }
+}
+
+#[async_trait]
+impl Sink for DorisSink {
+ async fn open(&mut self) -> Result<(), Error> {
+ // Constrain database/table BEFORE building the URL — they flow into
the
+ // path, so [A-Za-z0-9_]+ (narrower than Doris's own identifier rules)
+ // blocks path traversal in `/api/{db}/{table}/_stream_load`.
+ validate_identifier(&self.config.database, "database", self.id)?;
+ validate_identifier(&self.config.table, "table", self.id)?;
+
+ let base_url = build_stream_load_url(
+ self.id,
+ &self.config.fe_url,
+ &self.config.database,
+ &self.config.table,
+ )?;
+
+ // Doris permits passwordless users (e.g. a fresh `root`), so an empty
+ // password is valid — but almost always a misconfiguration. Warn,
don't
+ // fail, so local/dev setups still work.
+ if self.config.password.expose_secret().is_empty() {
+ warn!(
+ "Doris sink ID {} is configured with an empty password for
user '{}'; \
+ this is accepted but is usually a misconfiguration.",
+ self.id, self.config.username
+ );
+ }
+
+ // Warn when credentials would travel in cleartext. The FE -> BE 307
hop
+ // itself is guarded by `validate_redirect` (scheme downgrade + host
+ // allowlist); this covers the case where the FE itself is plain http.
+ if base_url.scheme().eq_ignore_ascii_case("http") {
+ let host = base_url.host_str().unwrap_or("");
+ // `host_str()` brackets IPv6 literals (`[::1]`); strip them and
parse
+ // as `IpAddr` to catch `127.0.0.1`, `::1`, and any loopback
spelling.
+ let is_loopback = host == "localhost"
+ || host
+ .trim_start_matches('[')
+ .trim_end_matches(']')
+ .parse::<std::net::IpAddr>()
+ .is_ok_and(|ip| ip.is_loopback());
+ if !is_loopback {
+ warn!(
+ "Doris sink ID {} is configured with http:// to
non-loopback host '{}'; \
+ credentials and message data will be transmitted in
cleartext. \
+ Use https:// in production.",
+ self.id, host
+ );
+ }
+ }
+
+ // Validate + precompute the optional Stream Load headers once. A bad
byte
+ // in `columns`/`where` fails here at startup, not silently per batch.
+ let max_filter_ratio_header = match self.config.max_filter_ratio {
+ Some(ratio) => {
+ // Doris's max_filter_ratio is a fraction in [0.0, 1.0]. A
NaN/inf or
+ // out-of-range value formats to a header-valid string (so the
ASCII
+ // check below would pass) but Doris rejects it on every batch
—
+ // catch it here at startup instead.
+ if !ratio.is_finite() || !(0.0..=1.0).contains(&ratio) {
+ return Err(Error::InvalidConfigValue(format!(
+ "Doris sink ID {}: max_filter_ratio must be a finite
value in [0.0, 1.0], got {ratio}",
+ self.id
+ )));
+ }
+ Some(validated_header(
+ "max_filter_ratio",
+ &ratio.to_string(),
+ self.id,
+ )?)
+ }
+ None => None,
+ };
+ let columns_header = match self.config.columns.as_deref() {
+ Some(columns) => Some(validated_header("columns", columns,
self.id)?),
+ None => None,
+ };
+ let where_header = match self.config.where_clause.as_deref() {
+ Some(where_clause) => Some(validated_header("where", where_clause,
self.id)?),
+ None => None,
+ };
+
+ self.connected = Some(Connected {
+ client: self.build_client()?,
+ base_url,
+ max_filter_ratio_header,
+ columns_header,
+ where_header,
+ allow_insecure_redirect:
self.config.allow_insecure_redirect.unwrap_or(false),
+ allowed_redirect_hosts: self.config.allowed_redirect_hosts.clone(),
+ });
+
+ info!(
+ "Opened Doris sink ID {} for {}.{} at {}",
+ self.id, self.config.database, self.config.table,
self.config.fe_url
+ );
+ Ok(())
+ }
+
+ async fn consume(
+ &self,
+ topic_metadata: &TopicMetadata,
+ messages_metadata: MessagesMetadata,
+ messages: Vec<ConsumedMessage>,
+ ) -> Result<(), Error> {
+ if messages.is_empty() {
+ return Ok(());
+ }
+
+ let total = messages.len();
+ debug!(
+ "Doris sink ID {} received {total} messages for {}.{}",
+ self.id, self.config.database, self.config.table
+ );
+
+ let batch_size = effective_batch_size(self.config.batch_size);
+ let label_prefix = self
+ .config
+ .label_prefix
+ .as_deref()
+ .unwrap_or(DEFAULT_LABEL_PREFIX);
+ let mut first_error: Option<Error> = None;
+
+ // Best-effort across chunks: on a per-chunk serialize/HTTP/status
failure
+ // we log it, keep the first error, and `continue` so later chunks
still
+ // land. The runtime commits this poll's consumer offset before
consume()
+ // runs, so returning early would drop the remaining chunks rather than
+ // replay them. The returned error is mapped to a 0/1 status at the FFI
+ // boundary (severity isn't propagated), and every chunk error is
already
+ // logged individually below — so first-error is sufficient.
+ //
+ // The lone hard-abort is a non-JSON payload (via `?`): a stream-wide
+ // schema-contract violation, not a transient chunk failure. Under the
+ // documented `schema = "json"` config the SDK drops non-JSON before
+ // consume() is called, so this stands as a defensive guard.
+ for chunk in messages.chunks(batch_size) {
+ let json_values: Vec<&simd_json::OwnedValue> = chunk
+ .iter()
+ .map(|m| match &m.payload {
+ Payload::Json(value) => Ok(value),
+ _ => {
+ error!(
+ "Doris sink ID {} received non-JSON payload
(schema={}); aborting poll",
+ self.id, messages_metadata.schema
+ );
+ Err(Error::InvalidPayloadType)
+ }
+ })
+ .collect::<Result<_, _>>()?;
+
+ // `chunks()` never yields an empty slice, so first/last are
present.
+ // Use `zip` + `continue` (not `.expect`) so that if a future
refactor
+ // ever breaks that invariant we neither fabricate offset 0 (which
+ // would alias the real offset-0 label and break idempotency) nor
+ // panic across the `extern "C"` FFI boundary (UB per the nomicon).
+ let Some((first_msg, last_msg)) = chunk.first().zip(chunk.last())
else {
+ continue;
+ };
+
+ let body = match simd_json::to_vec(&json_values) {
+ Ok(b) => Bytes::from(b),
+ Err(e) => {
+ error!("Doris sink ID {} failed to serialize batch: {e}",
self.id);
+ first_error.get_or_insert(Error::CannotStoreData(format!(
+ "Failed to serialize batch for Doris: {e}"
+ )));
+ continue;
+ }
+ };
+
+ let label = build_label(
+ label_prefix,
+ &topic_metadata.stream,
+ &topic_metadata.topic,
+ messages_metadata.partition_id,
+ first_msg.offset,
+ last_msg.offset,
+ );
+
+ match self.send_stream_load(&label, body).await {
+ Ok(response) => match classify_status(&response) {
+ Ok(()) => {
+ if response.number_filtered_rows > 0 {
+ // Filtered rows usually mean schema drift
upstream.
+ // Surface above debug so operators can alert on
it.
+ warn!(
+ "Doris sink ID {} loaded {} rows but FILTERED
{} rows for {}.{} (label={label}); \
+ likely schema drift upstream",
+ self.id,
+ response.number_loaded_rows,
+ response.number_filtered_rows,
+ self.config.database,
+ self.config.table,
+ );
+ } else {
+ debug!(
+ "Doris sink ID {} loaded {} rows into {}.{}
(label={label})",
+ self.id,
+ response.number_loaded_rows,
+ self.config.database,
+ self.config.table,
+ );
+ }
+ }
+ Err(e) => {
+ error!("Doris sink ID {} batch failed: {e}", self.id);
+ first_error.get_or_insert(e);
+ }
+ },
+ Err(e) => {
+ error!("Doris sink ID {} HTTP failed: {e}", self.id);
+ first_error.get_or_insert(e);
+ }
+ }
+ }
+
+ if let Some(err) = first_error {
+ return Err(err);
+ }
+ Ok(())
+ }
+
+ async fn close(&mut self) -> Result<(), Error> {
+ info!("Doris sink ID {} closed.", self.id);
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn make_config() -> DorisSinkConfig {
+ DorisSinkConfig {
+ fe_url: "http://localhost:8030".into(),
+ database: "test_db".into(),
+ table: "test_tbl".into(),
+ username: "root".into(),
+ password: SecretString::from("pw"),
+ label_prefix: None,
+ max_filter_ratio: None,
+ columns: None,
+ where_clause: None,
+ timeout: None,
+ connect_timeout: None,
+ batch_size: None,
+ allow_insecure_redirect: None,
+ allowed_redirect_hosts: None,
+ }
+ }
+
+ #[test]
+ fn stream_load_url_is_well_formed() {
+ let url = build_stream_load_url(1, "http://localhost:8030", "test_db",
"test_tbl").unwrap();
+ assert_eq!(
+ url.as_str(),
+ "http://localhost:8030/api/test_db/test_tbl/_stream_load"
+ );
+ }
+
+ #[test]
+ fn stream_load_url_handles_trailing_slash() {
+ let url =
+ build_stream_load_url(1, "http://localhost:8030/", "test_db",
"test_tbl").unwrap();
+ assert_eq!(
+ url.as_str(),
+ "http://localhost:8030/api/test_db/test_tbl/_stream_load"
+ );
+ }
+
+ #[test]
+ fn stream_load_url_rejects_garbage_fe_url() {
+ assert!(matches!(
+ build_stream_load_url(1, "not a url", "db", "tbl"),
+ Err(Error::InvalidConfigValue(_))
+ ));
+ }
+
+ #[test]
+ fn stream_load_url_rejects_non_http_scheme() {
+ // A parseable but non-HTTP base would only fail later, per-batch, at
send().
+ for fe_url in ["file:///etc", "ftp://host/path", "ws://host:8030"] {
+ assert!(
+ matches!(
+ build_stream_load_url(1, fe_url, "db", "tbl"),
+ Err(Error::InvalidConfigValue(_))
+ ),
+ "expected {fe_url} to be rejected at startup",
+ );
+ }
+ }
+
+ #[test]
+ fn label_is_deterministic() {
+ let a = build_label("iggy", "events", "orders", 7, 100, 199);
+ let b = build_label("iggy", "events", "orders", 7, 100, 199);
+ assert_eq!(a, b);
+ // Format:
{prefix}-{stream_san}-{topic_san}-{hash16}-{partition}-{first}-{last}
+ let parts: Vec<&str> = a.split('-').collect();
+ assert_eq!(parts.len(), 7);
+ assert_eq!(parts[0], "iggy");
+ assert_eq!(parts[1], "events");
+ assert_eq!(parts[2], "orders");
+ assert_eq!(parts[3].len(), LABEL_HASH_HEX_LEN);
+ assert!(parts[3].chars().all(|c| c.is_ascii_hexdigit()));
+ assert_eq!(parts[4], "7");
+ assert_eq!(parts[5], "100");
+ assert_eq!(parts[6], "199");
+ }
+
+ #[test]
+ fn label_sanitizes_illegal_chars() {
+ let label = build_label("iggy", "events.v1", "orders/inbound", 0, 0,
0);
+ // dots and slashes are not allowed in Doris labels.
+ assert!(!label.contains('.'));
+ assert!(!label.contains('/'));
+ }
+
+ #[test]
+ fn label_disambiguates_names_that_sanitize_identically() {
+ // The whole point of the joint hash: `events.v1` and `events_v1`
+ // collapse to the same sanitized form, but the hash is over the raw
+ // names so the final labels differ. Without this, two streams could
+ // silently dedupe against each other in Doris.
+ assert_ne!(
+ build_label("iggy", "events.v1", "orders", 0, 0, 0),
+ build_label("iggy", "events_v1", "orders", 0, 0, 0),
+ "labels must NOT collide for names that sanitize to the same
string"
+ );
+ }
+
+ #[test]
+ fn label_disambiguates_prefixes_that_sanitize_identically() {
+ // Two connectors writing the same stream/topic/partition/offset range
+ // but with prefixes that collapse to the same sanitized+truncated
+ // segment must still get distinct labels — otherwise Doris's label
+ // dedupe silently drops the second tenant's batch.
+ let a = build_label("prod_events_us_east_1", "events", "orders", 0, 0,
0);
+ let b = build_label("prod_events_us_east_2", "events", "orders", 0, 0,
0);
+ // Precondition: the sanitized prefix segments collide (both truncate
to
+ // the same 16 chars).
+ assert_eq!(
+ a.split('-').next(),
+ b.split('-').next(),
+ "precondition: sanitized prefixes should collide at 16 chars"
+ );
+ // ...but the full labels differ because the raw prefix is folded into
+ // the hash.
+ assert_ne!(
+ a, b,
+ "labels must NOT collide for prefixes that sanitize to the same
string"
+ );
+ }
+
+ #[test]
+ fn identity_hash_is_not_aliased_by_boundary_shift() {
+ // The joint hash is length-prefixed so shifting any boundary cannot
+ // produce the same digest: distinct (prefix, stream, topic) triples
must
+ // map to distinct hashes, otherwise two identities could share a label
+ // and silently dedupe in Doris.
+ assert_ne!(
+ identity_hash("iggy", "ab", "c"),
+ identity_hash("iggy", "a", "bc")
+ );
+ assert_ne!(
+ identity_hash("iggy", "events", "orders"),
+ identity_hash("iggy", "event", "sorders")
+ );
+ // The prefix participates too: shifting the prefix/stream boundary
must
+ // not alias.
+ assert_ne!(
+ identity_hash("ab", "c", "topic"),
+ identity_hash("a", "bc", "topic")
+ );
+ }
+
+ #[test]
+ fn label_stays_under_doris_128_char_cap() {
+ // Doris caps Stream Load labels at 128 chars. Build the worst case
+ // permitted by the connector: 100-char prefix/stream/topic (all of
+ // which get truncated), u64::MAX offsets, u32::MAX partition.
+ let prefix = "p".repeat(100);
+ let stream = "s".repeat(100);
+ let topic = "t".repeat(100);
+ let label = build_label(&prefix, &stream, &topic, u32::MAX, u64::MAX,
u64::MAX);
+ assert!(
+ label.len() <= 128,
+ "label exceeds Doris's 128-char cap: {} chars: {label}",
+ label.len()
+ );
+ }
+
+ #[test]
+ fn effective_batch_size_floors_at_one() {
+ assert_eq!(effective_batch_size(Some(0)), 1);
+ assert_eq!(effective_batch_size(None), DEFAULT_BATCH_SIZE as usize);
+ assert_eq!(effective_batch_size(Some(500)), 500);
+ }
+
+ #[test]
+ fn classify_success_returns_ok() {
+ let r = StreamLoadResponse {
+ status: "Success".into(),
+ message: String::new(),
+ number_loaded_rows: 10,
+ number_filtered_rows: 0,
+ };
+ assert!(classify_status(&r).is_ok());
+ }
+
+ #[test]
+ fn classify_label_already_exists_returns_ok() {
+ let r = StreamLoadResponse {
+ status: "Label Already Exists".into(),
+ message: String::new(),
+ number_loaded_rows: 0,
+ number_filtered_rows: 0,
+ };
+ assert!(classify_status(&r).is_ok());
+ }
+
+ #[test]
+ fn classify_publish_timeout_is_transient() {
+ let r = StreamLoadResponse {
+ status: "Publish Timeout".into(),
+ message: "be unreachable".into(),
+ number_loaded_rows: 0,
+ number_filtered_rows: 0,
+ };
+ assert!(matches!(
+ classify_status(&r).unwrap_err(),
+ Error::CannotStoreData(_)
+ ));
+ }
+
+ #[test]
+ fn classify_fail_is_permanent() {
+ let r = StreamLoadResponse {
+ status: "Fail".into(),
+ message: "schema mismatch".into(),
+ number_loaded_rows: 0,
+ number_filtered_rows: 0,
+ };
+ assert!(matches!(
+ classify_status(&r).unwrap_err(),
+ Error::PermanentHttpError(_)
+ ));
+ }
+
+ #[test]
+ fn parse_stream_load_response_handles_minimal_json() {
+ let body = r#"{"Status":"Success"}"#;
+ let r = parse_stream_load_response(body).unwrap();
+ assert_eq!(r.status, "Success");
+ assert_eq!(r.number_loaded_rows, 0);
+ }
+
+ #[test]
+ fn parse_stream_load_response_rejects_garbage_as_permanent() {
+ // An unparsable body must surface as PermanentHttpError so the
+ // runtime DLQs the batch instead of retrying the same garbage forever.
+ let body = "not json";
+ assert!(matches!(
+ parse_stream_load_response(body).unwrap_err(),
+ Error::PermanentHttpError(_)
+ ));
+ }
+
+ #[test]
+ fn validate_identifier_rejects_path_traversal() {
+ assert!(validate_identifier("../admin", "database", 1).is_err());
+ assert!(validate_identifier("foo/bar", "table", 1).is_err());
+ assert!(validate_identifier("", "database", 1).is_err());
+ assert!(validate_identifier("ok_name_1", "database", 1).is_ok());
+ }
+
+ #[test]
+ fn truncate_for_log_caps_long_input() {
+ let long = "x".repeat(10_000);
+ let truncated = truncate_for_log(&long, 100);
+ assert!(truncated.len() <= 100 + "...(truncated, total 10000
bytes)".len());
+ assert!(truncated.contains("(truncated"));
+ }
+
+ #[test]
+ fn truncate_for_log_passes_short_input_through() {
+ let short = "hello";
+ assert_eq!(truncate_for_log(short, 100), "hello");
+ }
+
+ #[test]
+ fn parse_duration_parses_and_falls_back() {
+ assert_eq!(parse_duration(Some("10s"), "30s"),
Duration::from_secs(10));
+ assert_eq!(parse_duration(None, "30s"), Duration::from_secs(30));
+ // A malformed value falls back to the default rather than erroring.
+ assert_eq!(
+ parse_duration(Some("not_a_duration"), "30s"),
+ Duration::from_secs(30)
+ );
+ // A zero duration is degenerate (reqwest times out every request
+ // immediately) and falls back to the default.
+ assert_eq!(parse_duration(Some("0s"), "30s"), Duration::from_secs(30));
+ assert_eq!(parse_duration(Some("0ms"), "5s"), Duration::from_secs(5));
+ }
+
+ #[tokio::test]
+ async fn open_rejects_out_of_range_max_filter_ratio() {
+ for ratio in [1.5_f64, -0.1_f64, f64::INFINITY, f64::NAN] {
+ let mut cfg = make_config();
+ cfg.max_filter_ratio = Some(ratio);
+ let mut sink = DorisSink::new(1, cfg);
+ assert!(
+ matches!(sink.open().await, Err(Error::InvalidConfigValue(_))),
+ "expected InvalidConfigValue for max_filter_ratio={ratio}",
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn open_accepts_in_range_max_filter_ratio() {
+ for ratio in [0.0_f64, 0.5_f64, 1.0_f64] {
+ let mut cfg = make_config();
+ cfg.max_filter_ratio = Some(ratio);
+ let mut sink = DorisSink::new(1, cfg);
+ assert!(
+ sink.open().await.is_ok(),
+ "expected open() to accept max_filter_ratio={ratio}",
+ );
+ }
+ }
+
+ fn url(s: &str) -> reqwest::Url {
+ reqwest::Url::parse(s).unwrap()
+ }
+
+ /// Build a `Connected` for redirect-validation tests: a throwaway client
and
+ /// no precomputed headers, with the redirect policy under test.
+ fn connected(
+ base: &str,
+ allow_insecure: bool,
+ allowed_hosts: Option<Vec<String>>,
+ ) -> Connected {
+ Connected {
+ client: reqwest::Client::new(),
+ base_url: url(base),
+ max_filter_ratio_header: None,
+ columns_header: None,
+ where_header: None,
+ allow_insecure_redirect: allow_insecure,
+ allowed_redirect_hosts: allowed_hosts,
+ }
+ }
+
+ #[test]
+ fn redirect_refuses_https_to_http_downgrade_by_default() {
+ // A compromised FE redirecting https -> http would leak Basic creds in
+ // cleartext. Refuse it unless explicitly opted in.
+ let err = connected("https://fe.doris:8030", false, None)
+ .validate_redirect(&url("http://attacker.evil/"), 1);
+ assert!(matches!(err, Err(Error::PermanentHttpError(_))));
+ }
+
+ #[test]
+ fn redirect_allows_downgrade_when_opted_in() {
+ // Known-insecure FE -> BE topology: operator accepts the risk.
+ assert!(
+ connected("https://fe.doris:8030", true, None)
+ .validate_redirect(&url("http://be.doris:8040/"), 1)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn redirect_allows_cross_host_same_scheme() {
+ // The normal FE -> BE hop: different host, same scheme, no allowlist.
+ assert!(
+ connected("https://fe.doris:8030", false, None)
+ .validate_redirect(&url("https://be.doris:8040/"), 1)
+ .is_ok()
+ );
+ // http -> http is not a downgrade.
+ assert!(
+ connected("http://fe.doris:8030", false, None)
+ .validate_redirect(&url("http://be.doris:8040/"), 1)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn redirect_refuses_non_http_scheme() {
+ // An http FE redirecting to a non-HTTP scheme slips past the downgrade
+ // check (the base isn't https) and, with no allowlist, the host check
is
+ // skipped — so it must be rejected by the scheme gate before creds are
+ // re-attached. Covers the default (no allowlist) path.
+ for target in [
+ "ftp://be.doris/",
+ "file:///etc/passwd",
+ "gopher://be.doris/",
+ ] {
+ assert!(
+ matches!(
+ connected("http://fe.doris:8030", false, None)
+ .validate_redirect(&url(target), 1),
+ Err(Error::PermanentHttpError(_))
+ ),
+ "scheme of {target} should be refused"
+ );
+ }
+ }
+
+ #[test]
+ fn redirect_enforces_host_allowlist_when_set() {
+ let allowed = vec!["be1.doris".to_string(), "be2.doris".to_string()];
+ // Target host not in the allowlist is refused.
+ assert!(matches!(
+ connected("http://fe.doris:8030", false, Some(allowed.clone()))
+ .validate_redirect(&url("http://attacker.evil:8040/"), 1),
+ Err(Error::PermanentHttpError(_))
+ ));
+ // Target host in the allowlist passes (bare host pins host only).
+ assert!(
+ connected("http://fe.doris:8030", false, Some(allowed))
+ .validate_redirect(&url("http://be2.doris:8040/"), 1)
+ .is_ok()
+ );
+ }
+
+ #[test]
+ fn redirect_allowlist_matches_ipv6_targets() {
+ // `host_str()` brackets IPv6 (`[::1]`), so a naive `rsplit_once(':')`
on a
+ // bare `::1` entry used to misparse to host ":" / port 1 and refuse a
+ // legitimate IPv6 BE redirect. Bare, bracketed, and port-pinned
entries
+ // must all match the same `http://[::1]:8040` target.
+ let target = "http://[::1]:8040/api/db/tbl/_stream_load";
+ for entry in ["::1", "[::1]", "[::1]:8040"] {
+ assert!(
+ connected("http://fe.doris:8030", false,
Some(vec![entry.to_string()]))
+ .validate_redirect(&url(target), 1)
+ .is_ok(),
+ "IPv6 allowlist entry {entry:?} should match {target}"
+ );
+ }
+ // A port-pinned IPv6 entry still refuses the wrong port.
+ assert!(matches!(
+ connected(
+ "http://fe.doris:8030",
+ false,
+ Some(vec!["[::1]:8040".to_string()])
+ )
+ .validate_redirect(&url("http://[::1]:6379/exfil"), 1),
+ Err(Error::PermanentHttpError(_))
+ ));
+ // A different IPv6 host is refused.
+ assert!(matches!(
+ connected("http://fe.doris:8030", false,
Some(vec!["::1".to_string()]))
+ .validate_redirect(&url("http://[fe80::1]:8040/"), 1),
+ Err(Error::PermanentHttpError(_))
+ ));
+ }
+
+ #[test]
+ fn redirect_allowlist_pins_port_when_specified() {
+ // A `host:port` entry pins the endpoint — an allowlisted host on a
+ // different (attacker) port is refused, closing the exfiltration
vector.
+ let allowed = vec!["be.doris:8040".to_string()];
+ assert!(
+ connected("http://fe.doris:8030", false, Some(allowed.clone()))
+ .validate_redirect(&url("http://be.doris:8040/"), 1)
+ .is_ok()
+ );
+ assert!(matches!(
+ connected("http://fe.doris:8030", false, Some(allowed))
+ .validate_redirect(&url("http://be.doris:6379/exfil"), 1),
+ Err(Error::PermanentHttpError(_))
+ ));
+ }
+
+ #[test]
+ fn auth_header_is_basic_b64() {
+ let sink = DorisSink::new(1, make_config());
+ // base64("root:pw") = cm9vdDpwdw==
+ assert_eq!(sink.auth_header.to_str().unwrap(), "Basic cm9vdDpwdw==");
+ // Marked sensitive so reqwest keeps it out of debug/trace output.
+ assert!(sink.auth_header.is_sensitive());
+ }
+
+ fn text_msg(offset: u64) -> ConsumedMessage {
+ ConsumedMessage {
+ id: offset as u128,
+ offset,
+ checksum: 0,
+ timestamp: 0,
+ origin_timestamp: 0,
+ headers: None,
+ payload: Payload::Text("not json".into()),
+ }
+ }
+
+ fn json_msg(offset: u64) -> ConsumedMessage {
+ let mut bytes = br#"{"k":1}"#.to_vec();
+ let value = simd_json::to_owned_value(&mut bytes).unwrap();
+ ConsumedMessage {
+ id: offset as u128,
+ offset,
+ checksum: 0,
+ timestamp: 0,
+ origin_timestamp: 0,
+ headers: None,
+ payload: Payload::Json(value),
+ }
+ }
+
+ fn topic_meta() -> TopicMetadata {
+ TopicMetadata {
+ stream: "events".into(),
+ topic: "orders".into(),
+ }
+ }
+
+ fn messages_meta() -> MessagesMetadata {
+ MessagesMetadata {
+ partition_id: 0,
+ current_offset: 0,
+ schema: iggy_connector_sdk::Schema::Json,
+ }
+ }
+
+ #[tokio::test]
+ async fn consume_aborts_on_first_non_json_payload() {
+ let sink = DorisSink::new(1, make_config());
+ let result = sink
+ .consume(&topic_meta(), messages_meta(), vec![text_msg(0)])
+ .await;
+ assert!(
+ matches!(result, Err(Error::InvalidPayloadType)),
+ "expected InvalidPayloadType, got {result:?}",
+ );
+ }
+
+ #[tokio::test]
+ async fn consume_aborts_on_non_json_in_mixed_batch() {
+ let sink = DorisSink::new(1, make_config());
+ let result = sink
+ .consume(
+ &topic_meta(),
+ messages_meta(),
+ vec![json_msg(0), text_msg(1)],
+ )
+ .await;
+ assert!(
+ matches!(result, Err(Error::InvalidPayloadType)),
+ "expected InvalidPayloadType, got {result:?}",
+ );
+ }
+
+ #[tokio::test]
+ async fn open_rejects_columns_header_with_control_chars() {
+ // A CR/LF in `columns` is an invalid HeaderValue. reqwest would defer
the
+ // failure to every `.send()`; we must fail fast at open() instead.
+ let mut cfg = make_config();
+ cfg.columns = Some("c1,\nc2".into());
+ let mut sink = DorisSink::new(1, cfg);
+ assert!(
+ matches!(sink.open().await, Err(Error::InvalidConfigValue(_))),
+ "expected InvalidConfigValue for a columns header with a newline",
+ );
+ }
+
+ /// Drives a real 307 FE -> BE redirect through `send_stream_load` and
+ /// asserts the connector rebuilds the *full* Stream Load request on the
+ /// redirected hop. The BE mock only matches when every header is present
+ /// — crucially the `Authorization` header, which reqwest would otherwise
+ /// strip on a cross-host redirect — so a regression that drops a header
+ /// makes the BE mock miss, yielding a 404 and a failed assertion.
+ #[tokio::test]
+ async fn redirect_rebuilds_full_request_on_be() {
+ use wiremock::matchers::{header, method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let server = MockServer::start().await;
+ let expected_auth = format!("Basic {}",
general_purpose::STANDARD.encode("root:pw"));
+ // Same host + scheme as the FE, so `validate_redirect` permits it —
+ // this is the normal Doris FE -> BE topology.
+ let be_url = format!("{}/be/_stream_load", server.uri());
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(ResponseTemplate::new(307).insert_header("Location",
be_url.as_str()))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ Mock::given(method("PUT"))
+ .and(path("/be/_stream_load"))
+ .and(header("authorization", expected_auth.as_str()))
+ .and(header("format", "json"))
+ .and(header("strip_outer_array", "true"))
+ .and(header("expect", "100-continue"))
+ .and(header("label", "iggy-test-label"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "Status": "Success",
+ "Message": "OK",
+ "NumberLoadedRows": 1,
+ "NumberFilteredRows": 0,
+ })))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+
+ let result = sink
+ .send_stream_load("iggy-test-label",
Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Ok(r) if r.status == "Success"),
+ "expected Ok(Success) after redirect, got {result:?}",
+ );
+ }
+
+ /// A redirect loop must surface as a *permanent* error: retrying just
+ /// re-walks the loop. (Regression guard for the HttpRequestFailed ->
+ /// PermanentHttpError reclassification.)
+ #[tokio::test]
+ async fn redirect_loop_is_permanent_error() {
+ use wiremock::matchers::{method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+ // Self-redirect: same host + scheme, so validate_redirect permits it,
+ // and the connector loops until MAX_REDIRECTS is exceeded.
+ let self_url = format!("{}/api/test_db/test_tbl/_stream_load",
server.uri());
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(ResponseTemplate::new(307).insert_header("Location",
self_url.as_str()))
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .send_stream_load("iggy-test-label",
Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Err(Error::PermanentHttpError(_))),
+ "expected PermanentHttpError on redirect loop, got {result:?}",
+ );
+ }
+
+ /// A redirect with no usable `Location` is malformed and permanent.
+ #[tokio::test]
+ async fn redirect_without_location_is_permanent_error() {
+ use wiremock::matchers::{method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(ResponseTemplate::new(307)) // no Location header
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .send_stream_load("iggy-test-label",
Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Err(Error::PermanentHttpError(_))),
+ "expected PermanentHttpError on missing Location, got {result:?}",
+ );
+ }
+
+ /// A relative `Location` is outside Doris's absolute-Location contract and
+ /// must be rejected as permanent rather than silently joined.
+ #[tokio::test]
+ async fn redirect_with_relative_location_is_permanent_error() {
+ use wiremock::matchers::{method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(ResponseTemplate::new(307).insert_header("Location",
"be_endpoint"))
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .send_stream_load("iggy-test-label",
Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Err(Error::PermanentHttpError(_))),
+ "expected PermanentHttpError on relative Location, got {result:?}",
+ );
+ }
+}
diff --git a/core/integration/Cargo.toml b/core/integration/Cargo.toml
index b0f30cbe5..81ba70ed9 100644
--- a/core/integration/Cargo.toml
+++ b/core/integration/Cargo.toml
@@ -53,6 +53,9 @@ iggy = { workspace = true }
iggy-cli = { workspace = true }
iggy_binary_protocol = { workspace = true }
iggy_common = { workspace = true }
+# Path-dep only so the Doris integration test can reuse the connector's pure
+# `build_label` function — keeping the test and production label format in
lock-step.
+iggy_connector_doris_sink = { path = "../connectors/sinks/doris_sink" }
iggy_connector_sdk = { workspace = true, features = ["api"] }
jsonwebtoken = { workspace = true }
keyring-core = { workspace = true }
@@ -82,6 +85,7 @@ sqlx = { workspace = true }
sysinfo = { workspace = true }
tempfile = { workspace = true }
test-case = { workspace = true }
+testcontainers = { workspace = true }
testcontainers-modules = { workspace = true }
tokio = { workspace = true, features = ["full", "test-util"] }
toml = { workspace = true }
@@ -90,7 +94,7 @@ tracing-subscriber = { workspace = true }
twox-hash = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
-wiremock = "0.6"
+wiremock = { workspace = true }
zip = { workspace = true }
[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os =
"dragonfly", target_os = "netbsd", target_os = "openbsd"))'.dependencies]
diff --git a/core/integration/tests/connectors/doris/doris_sink.rs
b/core/integration/tests/connectors/doris/doris_sink.rs
new file mode 100644
index 000000000..bf067d141
--- /dev/null
+++ b/core/integration/tests/connectors/doris/doris_sink.rs
@@ -0,0 +1,492 @@
+/*
+ * 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::create_test_messages;
+use crate::connectors::fixtures::{
+ DorisOps, DorisSinkColumnsMappingFixture, DorisSinkFixture,
DorisSinkMaxFilterRatioFixture,
+ DorisSinkPreCreatedFixture,
+};
+use bytes::Bytes;
+use iggy::prelude::{IggyMessage, Partitioning};
+use iggy_common::Identifier;
+use iggy_common::MessageClient;
+use integration::harness::seeds;
+use integration::iggy_harness;
+use serde::{Deserialize, Serialize};
+
+const TEST_TABLE: &str = "test_topic";
+
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/doris/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn given_existent_doris_table_should_store(
+ harness: &TestHarness,
+ fixture: DorisSinkPreCreatedFixture,
+) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let message_count = 10;
+ let test_messages = create_test_messages(message_count);
+ let payloads: Vec<Bytes> = test_messages
+ .iter()
+ .map(|m| Bytes::from(serde_json::to_vec(m).expect("serialize")))
+ .collect();
+
+ let mut messages: Vec<IggyMessage> = payloads
+ .iter()
+ .enumerate()
+ .map(|(i, p)| {
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(p.clone())
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ let count = fixture
+ .wait_for_rows(fixture.database(), TEST_TABLE, message_count as i64)
+ .await
+ .expect("rows");
+ assert_eq!(count, message_count as i64);
+}
+
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/doris/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn given_bulk_message_send_should_store(
+ harness: &TestHarness,
+ fixture: DorisSinkPreCreatedFixture,
+) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let message_count = 1000;
+ let test_messages = create_test_messages(message_count);
+
+ let mut messages: Vec<IggyMessage> = test_messages
+ .iter()
+ .enumerate()
+ .map(|(i, m)| {
+ let payload =
Bytes::from(serde_json::to_vec(m).expect("serialize"));
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(payload)
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ let count = fixture
+ .wait_for_rows(fixture.database(), TEST_TABLE, message_count as i64)
+ .await
+ .expect("rows");
+ assert_eq!(count, message_count as i64);
+}
+
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/doris/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn given_invalid_messages_should_skip_via_max_filter_ratio(
+ harness: &TestHarness,
+ fixture: DorisSinkMaxFilterRatioFixture,
+) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ // Two valid messages.
+ let valid: Vec<Bytes> = create_test_messages(2)
+ .iter()
+ .map(|m| Bytes::from(serde_json::to_vec(m).expect("serialize")))
+ .collect();
+
+ // One message whose JSON does not match the table columns. Doris will
+ // reject it as a "filtered" row; max_filter_ratio = 0.5 covers up to
+ // half the batch, so the load still succeeds and the two valid rows land.
+ #[derive(Debug, Serialize, Deserialize)]
+ struct WrongShape {
+ unrelated: f64,
+ }
+ let invalid =
+ Bytes::from(serde_json::to_vec(&WrongShape { unrelated: 1.0
}).expect("serialize"));
+
+ let payloads = [valid[0].clone(), invalid, valid[1].clone()];
+ let mut messages: Vec<IggyMessage> = payloads
+ .iter()
+ .enumerate()
+ .map(|(i, p)| {
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(p.clone())
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ let count = fixture
+ .wait_for_rows(fixture.database(), TEST_TABLE, 2)
+ .await
+ .expect("rows");
+ assert_eq!(count, 2, "only the two valid rows should land");
+}
+
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/doris/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn given_replayed_label_should_dedupe(harness: &TestHarness, fixture:
DorisSinkFixture) {
+ let db = fixture.database();
+ // This fixture variant does NOT pre-create the table; the test creates
+ // it explicitly before producing messages so we can drop and recreate
+ // between rounds without disturbing the connector runtime's state.
+ fixture
+ .create_table(db, TEST_TABLE)
+ .await
+ .expect("create table");
+
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let message_count = 5;
+ let test_messages = create_test_messages(message_count);
+
+ // Round 1: send and observe rows land.
+ let mut round1: Vec<IggyMessage> = test_messages
+ .iter()
+ .enumerate()
+ .map(|(i, m)| {
+ let payload =
Bytes::from(serde_json::to_vec(m).expect("serialize"));
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(payload)
+ .build()
+ .expect("build message")
+ })
+ .collect();
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut round1,
+ )
+ .await
+ .expect("send messages round 1");
+
+ let count = fixture
+ .wait_for_rows(db, TEST_TABLE, message_count as i64)
+ .await
+ .expect("rows after round 1");
+ assert_eq!(count, message_count as i64);
+
+ // Round 2: send the same payloads again with the same Iggy IDs. The
+ // connector generates a deterministic Stream Load label per (stream,
+ // topic, partition, first_offset, last_offset). Because Iggy assigns
+ // monotonic offsets, the second batch lands at different offsets and
+ // gets a different label — so duplicates WOULD land if the replay
+ // deduplication only relied on Iggy IDs. The point of this test is the
+ // converse case below.
+ //
+ // Instead we verify the label-dedupe path by issuing a manual Stream
+ // Load with the SAME label as the one the connector just used. Doris
+ // must respond with "Label Already Exists", which the connector maps
+ // to Ok — so the row count must NOT increase.
+ // Use the fixture's count helper (raw_sql under the hood) — Doris's
+ // MySQL frontend rejects prepared sqlx::query/query_scalar statements.
+ let row_before = fixture
+ .count_rows(db, TEST_TABLE)
+ .await
+ .expect("count before");
+ assert_eq!(row_before, message_count as i64);
+
+ // Replay the same offsets => same label => Doris dedupes server-side.
+ // Reuse the connector's own `build_label` so the test cannot drift from
+ // the production label format.
+ let label = iggy_connector_doris_sink::build_label(
+ "iggy_test",
+ seeds::names::STREAM,
+ seeds::names::TOPIC,
+ 0,
+ 0,
+ (message_count - 1) as u64,
+ );
+
+ let body = serde_json::to_vec(&test_messages).expect("serialize replay
body");
+
+ let client_http = reqwest::Client::builder()
+ .redirect(reqwest::redirect::Policy::none())
+ .build()
+ .unwrap();
+
+ let url = format!(
+ "{}/api/{db}/{TEST_TABLE}/_stream_load",
+ fixture.container().fe_url()
+ );
+
+ // Manually follow the FE -> BE 307 once. Cap iterations so a misbehaving
+ // cluster can't hang the test process indefinitely.
+ const MAX_REDIRECTS: u8 = 5;
+ let mut current_url = url.clone();
+ let mut redirects = 0u8;
+ let response = loop {
+ let resp = client_http
+ .put(¤t_url)
+ .basic_auth("root", Some(""))
+ // Doris's FE rejects Stream Load PUTs that don't carry
+ // `Expect: 100-continue` (the connector sets this; the manual
+ // probe needs the same).
+ .header(reqwest::header::EXPECT, "100-continue")
+ .header("format", "json")
+ .header("strip_outer_array", "true")
+ .header("label", &label)
+ .body(body.clone())
+ .send()
+ .await
+ .expect("stream load");
+ let status = resp.status();
+ if status == reqwest::StatusCode::TEMPORARY_REDIRECT
+ || status == reqwest::StatusCode::PERMANENT_REDIRECT
+ {
+ redirects += 1;
+ assert!(
+ redirects <= MAX_REDIRECTS,
+ "exceeded {MAX_REDIRECTS} redirects following Stream Load"
+ );
+ let loc = resp
+ .headers()
+ .get(reqwest::header::LOCATION)
+ .and_then(|v| v.to_str().ok())
+ .expect("Location header")
+ .to_string();
+ current_url = loc;
+ continue;
+ }
+ break resp;
+ };
+ let body_text = response.text().await.expect("body");
+ // Require Doris to report dedupe explicitly. A `"Success"` here would
+ // mean the label drifted from what the connector built — which doubles
+ // rows and is caught by `row_after == message_count`, but we want the
+ // dedupe path itself to be load-bearing in this test.
+ assert!(
+ body_text.contains("Label Already Exists"),
+ "expected Doris to dedupe by label, got: {body_text}"
+ );
+
+ let row_after = fixture
+ .count_rows(db, TEST_TABLE)
+ .await
+ .expect("count after");
+ assert_eq!(
+ row_after, message_count as i64,
+ "label dedupe must not produce duplicates"
+ );
+}
+
+/// Connector targets a table that does not exist. Every Stream Load PUT
+/// gets a `Status: "Fail"` (or the FE returns a 4xx) — the connector must
+/// classify that as `PermanentHttpError`, NOT `CannotStoreData`. The
+/// integration assertions here are coarse on purpose (we can't read the
+/// connector's internal Result), but they prove three things any future
+/// regression would break:
+/// 1. The connector does not silently auto-create the missing table.
+/// 2. The connector does not write into any *other* table by mistake.
+/// 3. The Doris cluster (and our connection pool) survives the failed
+/// load attempts intact.
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/doris/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn given_missing_target_table_should_not_create_or_corrupt(
+ harness: &TestHarness,
+ fixture: DorisSinkFixture,
+) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let test_messages = create_test_messages(5);
+ let mut messages: Vec<IggyMessage> = test_messages
+ .iter()
+ .enumerate()
+ .map(|(i, m)| {
+ let payload =
Bytes::from(serde_json::to_vec(m).expect("serialize"));
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(payload)
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ // Give the connector enough time to receive the batch and attempt at
+ // least one Stream Load PUT against the missing table.
+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+
+ let db = fixture.database();
+
+ // 1. The target table must NOT have been auto-created.
+ let exists = fixture
+ .table_exists(db, TEST_TABLE)
+ .await
+ .expect("information_schema query");
+ assert!(
+ !exists,
+ "Doris sink must NOT auto-create the target table on a failed load"
+ );
+
+ // 2 + 3. The cluster is still healthy and the per-test database has no
+ // rogue tables — proves we didn't write anywhere unexpected and that the
+ // connector's HTTP failures didn't take Doris down.
+ let pool = fixture.pool().await.expect("pool after failed loads");
+ use sqlx::Row;
+ let rows = sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
+ "SELECT TABLE_NAME FROM information_schema.tables \
+ WHERE TABLE_SCHEMA = '{db}'"
+ )))
+ .fetch_all(&pool)
+ .await
+ .expect("list tables in test database");
+ let tables: Vec<String> = rows
+ .iter()
+ .filter_map(|r| r.try_get::<String, _>("TABLE_NAME").ok())
+ .collect();
+ assert!(
+ tables.is_empty(),
+ "test database {db} should be empty after failed loads, found:
{tables:?}"
+ );
+}
+
+/// Verifies the `columns` Stream Load config is wired through end-to-end.
+/// The pre-created table has an extra `calculated INT NOT NULL` column that
+/// is NOT in the JSON payload; the only way the load succeeds is if the
+/// connector forwards the configured `columns` header to Doris so it can
+/// derive `calculated = count + 1` server-side.
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/doris/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn given_columns_config_should_apply_derived_expression(
+ harness: &TestHarness,
+ fixture: DorisSinkColumnsMappingFixture,
+) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let message_count = 5;
+ let test_messages = create_test_messages(message_count);
+ let mut messages: Vec<IggyMessage> = test_messages
+ .iter()
+ .enumerate()
+ .map(|(i, m)| {
+ let payload =
Bytes::from(serde_json::to_vec(m).expect("serialize"));
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(payload)
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ let db = fixture.database();
+ let count = fixture
+ .wait_for_rows(db, TEST_TABLE, message_count as i64)
+ .await
+ .expect("rows");
+ assert_eq!(count, message_count as i64);
+
+ // `create_test_messages` produces count = (i - 1) * 10 for i in 1..=N,
+ // so SUM(count) = 0 + 10 + 20 + 30 + 40 = 100 and with the derived
+ // expression `calculated = count + 1` we expect SUM(calculated - count)
+ // to equal exactly the row count (one per row).
+ let pool = fixture.pool().await.expect("pool");
+ use sqlx::Row;
+ let row = sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
+ "SELECT SUM(calculated - `count`) AS delta FROM {db}.{TEST_TABLE}"
+ )))
+ .fetch_one(&pool)
+ .await
+ .expect("sum query");
+ let delta: i64 = row
+ .try_get::<i64, _>("delta")
+ .expect("delta column decodes as i64");
+ assert_eq!(
+ delta, message_count as i64,
+ "expected calculated = count + 1 per row (delta sum =
{message_count}), got {delta}"
+ );
+}
diff --git a/core/integration/tests/connectors/doris/mod.rs
b/core/integration/tests/connectors/doris/mod.rs
new file mode 100644
index 000000000..b75e42c97
--- /dev/null
+++ b/core/integration/tests/connectors/doris/mod.rs
@@ -0,0 +1,20 @@
+/*
+ * 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 doris_sink;
diff --git a/.config/nextest.toml
b/core/integration/tests/connectors/doris/sink.toml
similarity index 64%
copy from .config/nextest.toml
copy to core/integration/tests/connectors/doris/sink.toml
index fb488b694..59f191e0a 100644
--- a/.config/nextest.toml
+++ b/core/integration/tests/connectors/doris/sink.toml
@@ -15,14 +15,6 @@
# specific language governing permissions and limitations
# under the License.
-[[profile.default.overrides]]
-# This is a solution (or actually a workaround) for the problem that nextest
does not support
-# #[serial] macro which shall enforce sequential execution of the test case.
-filter = 'package(integration) and
test(cli::system::test_cli_session_scenario::should_be_successful)'
-threads-required = "num-cpus"
-
-[profile.default]
-slow-timeout = { period = "30s", terminate-after = 4 }
-
-[profile.ci]
-retries = 3
+[connectors]
+config_type = "local"
+config_dir = "../connectors/sinks/doris_sink"
diff --git a/core/integration/tests/connectors/fixtures/doris/container.rs
b/core/integration/tests/connectors/fixtures/doris/container.rs
new file mode 100644
index 000000000..e18e53a60
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/doris/container.rs
@@ -0,0 +1,717 @@
+/*
+ * 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.
+ */
+
+// Doris fixture. One Doris container is shared across every doris test in this
+// CI job (or local `cargo test` session) via testcontainers'
reusable-containers
+// feature: the container is named `iggy-doris-test` and marked
+// `ReuseDirective::Always`, so the first test creates it and every subsequent
+// test (in this or any other test process on the same Docker daemon) attaches
+// to it. This is what makes `cargo test` and CI follow the same path — the old
+// orchestration in `.github/actions/rust/pre-merge` is gone.
+//
+// Cross-process sharing matters because nextest runs each test in its own
+// process. An in-process `OnceCell` would reboot Doris every time, and the new
+// container would race the previous one for the BE's 1:1-mapped 8040 port.
+//
+// The container outlives the test session by design (that's what enables
+// reuse). CI runners are ephemeral so it dies with them; locally, `docker rm
+// -f iggy-doris-test` forces a fresh boot.
+//
+// Two host-level prerequisites cannot live inside the container:
+// * `vm.max_map_count >= 2_000_000` — Doris 4.0.3's `start_be.sh` hard-exits
+// otherwise. `check_vm_max_map_count` reads `/proc/sys/vm/max_map_count`
+// and returns a self-describing error if the host is below the threshold.
+// * Routable Docker bridge IPs — the FE returns a 307 to a BE address that
+// is the BE container's internal Docker network IP. Those addresses are
+// routable from a Linux Docker host but not from macOS Docker Desktop, so
+// these tests are effectively Linux-only without extra networking work.
+
+use async_trait::async_trait;
+use integration::harness::{TestBinaryError, TestFixture};
+use sqlx::mysql::{MySqlConnectOptions, MySqlPool, MySqlPoolOptions};
+use std::collections::HashMap;
+use std::time::Duration;
+use testcontainers_modules::testcontainers::core::wait::HttpWaitStrategy;
+use testcontainers_modules::testcontainers::core::{IntoContainerPort, WaitFor};
+use testcontainers_modules::testcontainers::runners::AsyncRunner;
+use testcontainers_modules::testcontainers::{
+ ContainerAsync, GenericImage, ImageExt, ReuseDirective,
+};
+use tokio::time::sleep;
+use tracing::info;
+use uuid::Uuid;
+
+const DORIS_IMAGE: &str = "apache/doris";
+// Apache's maintained single-container line is now tagged `<version>-all` /
+// `<version>-all-slim` (the old one-off `doris-all-in-one-2.1.0` was pushed
once
+// in 2024 and never refreshed). `-slim` is the smaller base, so it pulls
faster
+// on CI runners.
+const DORIS_TAG: &str = "4.0.3-all-slim";
+// Fixed name + `ReuseDirective::Always` is what makes the container survive
+// across nextest's per-test processes. Stable name means every test process
+// inspecting the Docker daemon finds the same one.
+const DORIS_CONTAINER_NAME: &str = "iggy-doris-test";
+const FE_HTTP_PORT: u16 = 8030;
+const FE_MYSQL_PORT: u16 = 9030;
+const BE_HTTP_PORT: u16 = 8040;
+const FE_HEALTH_ENDPOINT: &str = "/api/health";
+
+const DEFAULT_TEST_TABLE: &str = "test_topic";
+const DEFAULT_USER: &str = "root";
+const DEFAULT_PASSWORD: &str = "";
+
+// Doris's all-in-one image typically reports `Alive: true` within ~40s of
+// container start, but a cold Docker host or constrained CI runner can push
+// that out. The wait window is sized to swallow those outliers without
+// hanging a test slot for a truly broken cluster.
+const DEFAULT_BE_REGISTRATION_ATTEMPTS: usize = 180;
+const DEFAULT_BE_REGISTRATION_INTERVAL_MS: u64 = 2000;
+
+// 90 seconds (180 × 500ms) — bulk tests with 1000 rows can take ~30 s on a
+// fresh container, and a Doris instance under memory pressure (e.g. when it
+// is the 3rd or 4th container to spin up serially) takes longer.
+pub const DEFAULT_POLL_ATTEMPTS: usize = 180;
+pub const DEFAULT_POLL_INTERVAL_MS: u64 = 500;
+
+// vm.max_map_count threshold Doris 4.0.3's start_be.sh enforces (`exit 1` if
+// the running kernel reports less). The number is Doris's, not ours. Only
+// referenced by the Linux precheck; gated to keep non-Linux builds
warning-free.
+#[cfg(target_os = "linux")]
+const REQUIRED_VM_MAX_MAP_COUNT: u64 = 2_000_000;
+
+// 4.0.3 sizes the FE for a dedicated host (8GB -Xms) and lets the BE consume
+// ~90% of RAM via `mem_limit = auto`. Both blow past a 16GB CI runner that
also
+// has to host the cargo build + the test binaries; cap both to fit beside that
+// workload. ~Matches the 2.1.0 image's effective footprint.
+const FE_HEAP_OVERRIDE_SED: &str = "s/-Xmx8192m -Xms8192m/-Xmx2048m
-Xms2048m/";
+const BE_MEM_LIMIT_OVERRIDE: &str = "mem_limit = 4096M";
+
+const ENV_SINK_FE_URL: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_FE_URL";
+const ENV_SINK_DATABASE: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_DATABASE";
+const ENV_SINK_TABLE: &str = "IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_TABLE";
+const ENV_SINK_USERNAME: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_USERNAME";
+const ENV_SINK_PASSWORD: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_PASSWORD";
+const ENV_SINK_LABEL_PREFIX: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_LABEL_PREFIX";
+const ENV_SINK_MAX_FILTER_RATIO: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_MAX_FILTER_RATIO";
+const ENV_SINK_COLUMNS: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_COLUMNS";
+const ENV_SINK_BATCH_SIZE: &str =
"IGGY_CONNECTORS_SINK_DORIS_PLUGIN_CONFIG_BATCH_SIZE";
+const ENV_SINK_STREAMS_0_STREAM: &str =
"IGGY_CONNECTORS_SINK_DORIS_STREAMS_0_STREAM";
+const ENV_SINK_STREAMS_0_TOPICS: &str =
"IGGY_CONNECTORS_SINK_DORIS_STREAMS_0_TOPICS";
+const ENV_SINK_STREAMS_0_SCHEMA: &str =
"IGGY_CONNECTORS_SINK_DORIS_STREAMS_0_SCHEMA";
+const ENV_SINK_STREAMS_0_CONSUMER_GROUP: &str =
+ "IGGY_CONNECTORS_SINK_DORIS_STREAMS_0_CONSUMER_GROUP";
+const ENV_SINK_PATH: &str = "IGGY_CONNECTORS_SINK_DORIS_PATH";
+
+/// The schema used by `TestMessage` in the integration tests.
+///
+/// `replication_num = 1` because the all-in-one container has a single BE. The
+/// table storage medium is left to Doris's default (the 2.1 all-in-one image
+/// advertised HDD-only and needed an explicit `storage_medium = HDD` override;
+/// the 4.0 image no longer does).
+const TEST_TABLE_DDL_TEMPLATE: &str = "
+CREATE TABLE IF NOT EXISTS {db}.{table} (
+ id BIGINT NOT NULL,
+ name VARCHAR(64) NOT NULL,
+ count INT NOT NULL,
+ amount DOUBLE NOT NULL,
+ active BOOLEAN NOT NULL,
+ timestamp BIGINT NOT NULL
+)
+DUPLICATE KEY(id)
+DISTRIBUTED BY HASH(id) BUCKETS 1
+PROPERTIES (
+ \"replication_num\" = \"1\"
+);
+";
+
+/// Same shape as `TEST_TABLE_DDL_TEMPLATE` plus an extra `calculated INT NOT
+/// NULL` column. The columns-mapping test populates `calculated` via a Stream
+/// Load `columns` derived expression (`calculated = count + 1`); without that
+/// header the load would fail because `calculated` has no source field in the
+/// JSON payload.
+const TEST_TABLE_WITH_CALCULATED_DDL_TEMPLATE: &str = "
+CREATE TABLE IF NOT EXISTS {db}.{table} (
+ id BIGINT NOT NULL,
+ name VARCHAR(64) NOT NULL,
+ count INT NOT NULL,
+ amount DOUBLE NOT NULL,
+ active BOOLEAN NOT NULL,
+ timestamp BIGINT NOT NULL,
+ calculated INT NOT NULL
+)
+DUPLICATE KEY(id)
+DISTRIBUTED BY HASH(id) BUCKETS 1
+PROPERTIES (
+ \"replication_num\" = \"1\"
+);
+";
+
+/// Stream Load `columns` header used by `DorisSinkColumnsMappingFixture`. The
+/// six leading names match the JSON payload's keys; `calculated = count + 1`
+/// instructs Doris to derive the seventh column from the loaded `count`.
+pub const COLUMNS_MAPPING_HEADER: &str =
+ "id, name, count, amount, active, timestamp, calculated = count + 1";
+
+/// Linux-only host precheck. macOS / Windows have no
`/proc/sys/vm/max_map_count`
+/// (and the all-in-one image is already unusable on macOS for the BE-redirect
+/// routing reason — see the file header). Treat the check as a no-op there;
the
+/// container start will surface any real issue.
+#[cfg(target_os = "linux")]
+fn check_vm_max_map_count() -> Result<(), TestBinaryError> {
+ let v: u64 = std::fs::read_to_string("/proc/sys/vm/max_map_count")
+ .ok()
+ .and_then(|s| s.trim().parse().ok())
+ .unwrap_or(0);
+ if v < REQUIRED_VM_MAX_MAP_COUNT {
+ return Err(TestBinaryError::FixtureSetup {
+ fixture_type: "SharedDoris".to_string(),
+ message: format!(
+ "Doris 4.0.3 BE refuses to start unless vm.max_map_count >= \
+ {REQUIRED_VM_MAX_MAP_COUNT} (current: {v}). \
+ Run: `sudo sysctl -w
vm.max_map_count={REQUIRED_VM_MAX_MAP_COUNT}`"
+ ),
+ });
+ }
+ Ok(())
+}
+
+#[cfg(not(target_os = "linux"))]
+fn check_vm_max_map_count() -> Result<(), TestBinaryError> {
+ Ok(())
+}
+
+pub struct DorisContainer {
+ // Held only so testcontainers' Drop runs on test exit.
ReuseDirective::Always
+ // makes that Drop a no-op (the container is left running for the next test
+ // to attach to), but keeping the handle around is still required to keep
+ // the bollard client connection alive.
+ _container: ContainerAsync<GenericImage>,
+ fe_url: String,
+ fe_mysql_host_port: u16,
+ // Unique per test, so many tests can share one cluster without their loads
+ // colliding. Created during setup; the connector writes into it.
+ database: String,
+}
+
+impl DorisContainer {
+ pub async fn start() -> Result<Self, TestBinaryError> {
+ check_vm_max_map_count()?;
+
+ // Custom entrypoint patches FE heap + BE mem_limit before handing off
+ // to the image's normal entry_point.sh; SKIP_CHECK_ULIMIT bypasses the
+ // image's swap/ulimit gates so we needn't swapoff the runner. Only
+ // runs on first boot; ignored on attach because the existing container
+ // already has its config applied.
+ let entrypoint_cmd = format!(
+ "sed -i '{FE_HEAP_OVERRIDE_SED}' /opt/apache-doris/fe/conf/fe.conf
&& \
+ echo '{BE_MEM_LIMIT_OVERRIDE}' >>
/opt/apache-doris/be/conf/be.conf && \
+ exec bash /usr/local/bin/entry_point.sh"
+ );
+
+ // FE HTTP and FE MySQL get ephemeral host ports (the connector and
+ // tests connect via the resolved mapping). BE HTTP must be pinned
+ // 1:1 — the FE always returns Location: http://127.0.0.1:8040/...
+ // for the Stream Load redirect, and that's only reachable from the
+ // host if container:8040 is bound to host:8040.
+ //
+ // `with_container_name` + `with_reuse(Always)` is what makes the
+ // container survive across nextest's per-test processes: the first
+ // test creates `iggy-doris-test`, every later test (in any process)
+ // attaches to it. The 1:1 BE port is therefore held continuously by
+ // one container, never racing with itself across container restarts.
+ let container = GenericImage::new(DORIS_IMAGE, DORIS_TAG)
+ // GenericImage's own with_entrypoint/with_wait_for must come
before
+ // any ImageExt method, which turns GenericImage into
ContainerRequest.
+ .with_entrypoint("bash")
+ .with_wait_for(WaitFor::http(
+ HttpWaitStrategy::new(FE_HEALTH_ENDPOINT)
+ .with_port(FE_HTTP_PORT.tcp())
+ .with_expected_status_code(200u16),
+ ))
+ .with_env_var("SKIP_CHECK_ULIMIT", "true")
+ .with_cmd(["-c", entrypoint_cmd.as_str()])
+ .with_mapped_port(0, FE_HTTP_PORT.tcp())
+ .with_mapped_port(0, FE_MYSQL_PORT.tcp())
+ .with_mapped_port(BE_HTTP_PORT, BE_HTTP_PORT.tcp())
+ .with_container_name(DORIS_CONTAINER_NAME)
+ .with_reuse(ReuseDirective::Always)
+ .start()
+ .await
+ .map_err(|e| TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: format!("Failed to start container: {e}"),
+ })?;
+
+ let ports = container
+ .ports()
+ .await
+ .map_err(|e| TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: format!("Failed to read mapped ports: {e}"),
+ })?;
+
+ let fe_http_host_port =
ports.map_to_host_port_ipv4(FE_HTTP_PORT).ok_or_else(|| {
+ TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: "No host mapping for Doris FE HTTP port".to_string(),
+ }
+ })?;
+ let fe_mysql_host_port =
ports.map_to_host_port_ipv4(FE_MYSQL_PORT).ok_or_else(|| {
+ TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: "No host mapping for Doris FE MySQL port".to_string(),
+ }
+ })?;
+
+ info!(
+ "Doris container ready (name={DORIS_CONTAINER_NAME}): FE HTTP ->
{fe_http_host_port}, FE MySQL -> {fe_mysql_host_port}"
+ );
+
+ // A fresh database per test so any number of tests can share one
+ // cluster without their Stream Loads colliding. `simple()` drops the
+ // hyphens so the name stays a valid Doris identifier ([A-Za-z0-9_]).
+ let database = format!("iggy_test_db_{}", Uuid::new_v4().simple());
+
+ let this = Self {
+ _container: container,
+ fe_url: format!("http://127.0.0.1:{fe_http_host_port}"),
+ fe_mysql_host_port,
+ database,
+ };
+
+ // Required on first boot; fast-paths in <1s once the BE is already
+ // alive on subsequent test-process attaches.
+ this.wait_for_be_alive().await?;
+ this.create_test_database().await?;
+ Ok(this)
+ }
+
+ pub fn fe_url(&self) -> String {
+ self.fe_url.clone()
+ }
+
+ /// The unique per-test database this fixture's connector writes into.
+ pub fn database(&self) -> &str {
+ &self.database
+ }
+
+ async fn create_pool(&self) -> Result<MySqlPool, TestBinaryError> {
+ // sqlx ordinarily emits a `SET sql_mode = (SELECT CONCAT(...))`
+ // session init when connecting to MySQL. Doris rejects that with
+ // "Set statement does't support non-constant expr.", which kills
+ // the connection before any query runs. Disabling these two flags
+ // suppresses both offending SET statements.
+ let opts = MySqlConnectOptions::new()
+ .host("127.0.0.1")
+ .port(self.fe_mysql_host_port)
+ .username(DEFAULT_USER)
+ .pipes_as_concat(false)
+ .no_engine_substitution(false);
+
+ MySqlPoolOptions::new()
+ .max_connections(2)
+ .acquire_timeout(Duration::from_secs(10))
+ .connect_with(opts)
+ .await
+ .map_err(|e| TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: format!("Failed to connect to Doris over MySQL: {e}"),
+ })
+ }
+
+ /// Doris reports BE alive=true via `SHOW BACKENDS` only after the BE has
+ /// successfully registered with the FE. Stream Load won't work until then,
+ /// so we block here.
+ async fn wait_for_be_alive(&self) -> Result<(), TestBinaryError> {
+ let mut last_diag = String::from("never connected");
+ for attempt in 0..DEFAULT_BE_REGISTRATION_ATTEMPTS {
+ match self.create_pool().await {
+ Ok(pool) => {
+ // `SHOW BACKENDS` cannot be sent through `sqlx::query` —
+ // sqlx prepares the statement and Doris's MySQL frontend
+ // rejects PREPARE for anything other than SELECT/INSERT
+ // ("Only support prepare SelectStmt or InsertStmt now").
+ // `sqlx::raw_sql` skips PREPARE and dispatches the bytes
+ // directly, so SHOW/DDL works.
+ match sqlx::raw_sql("SHOW
BACKENDS").fetch_all(&pool).await {
+ Ok(rows) => {
+ let any_alive = rows.iter().any(|row| {
+ use sqlx::Row;
+ row.try_get::<String, _>("Alive")
+ .map(|v| v.eq_ignore_ascii_case("true"))
+ .unwrap_or(false)
+ });
+ if any_alive {
+ info!("Doris BE registered alive after
{attempt} attempts");
+ return Ok(());
+ }
+ last_diag = format!("{} backend rows, none alive",
rows.len());
+ }
+ Err(e) => {
+ last_diag = format!("SHOW BACKENDS failed: {e}");
+ }
+ }
+ }
+ Err(e) => {
+ last_diag = format!("connect failed: {e}");
+ }
+ }
+ // Surface progress every ~30 seconds so a stuck cluster is
+ // distinguishable from one that's still bootstrapping.
+ if attempt > 0 && attempt % (30_000 /
DEFAULT_BE_REGISTRATION_INTERVAL_MS as usize) == 0
+ {
+ info!("Doris BE not yet alive after {attempt} attempts
({last_diag})");
+ }
+
sleep(Duration::from_millis(DEFAULT_BE_REGISTRATION_INTERVAL_MS)).await;
+ }
+ Err(TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: format!(
+ "Doris BE did not register within
{DEFAULT_BE_REGISTRATION_ATTEMPTS} attempts ({last_diag})"
+ ),
+ })
+ }
+
+ async fn create_test_database(&self) -> Result<(), TestBinaryError> {
+ let pool = self.create_pool().await?;
+ // raw_sql avoids the PREPARE path Doris doesn't accept for DDL.
+ // sqlx 0.9's `raw_sql` requires `SqlSafeStr`; the input here is a UUID
+ // we just generated, so `AssertSqlSafe` is appropriate.
+ sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
+ "CREATE DATABASE IF NOT EXISTS {}",
+ self.database
+ )))
+ .execute(&pool)
+ .await
+ .map_err(|e| TestBinaryError::FixtureSetup {
+ fixture_type: "DorisContainer".to_string(),
+ message: format!("Failed to create test database: {e}"),
+ })?;
+ Ok(())
+ }
+}
+
+#[async_trait]
+pub trait DorisOps: Sync {
+ fn container(&self) -> &DorisContainer;
+
+ /// The unique per-test database this fixture's connector writes into.
+ fn database(&self) -> &str {
+ self.container().database()
+ }
+
+ async fn pool(&self) -> Result<MySqlPool, TestBinaryError> {
+ self.container().create_pool().await
+ }
+
+ async fn create_table(&self, database: &str, table: &str) -> Result<(),
TestBinaryError> {
+ self.create_table_with_template(database, table,
TEST_TABLE_DDL_TEMPLATE)
+ .await
+ }
+
+ async fn create_table_with_template(
+ &self,
+ database: &str,
+ table: &str,
+ ddl_template: &str,
+ ) -> Result<(), TestBinaryError> {
+ let pool = self.pool().await?;
+ let ddl = ddl_template
+ .replace("{db}", database)
+ .replace("{table}", table);
+
+ // Even after `SHOW BACKENDS` reports `Alive: true`, the FE briefly
+ // doesn't know about the BE's storage paths and rejects CREATE TABLE
+ // with "Failed to find enough backend ... storage medium: ...". The
+ // BE typically catches up within a couple of seconds once it sends
+ // its first tablet/disk report. Retry through that window.
+ let mut last_err: Option<sqlx::Error> = None;
+ for _ in 0..30 {
+ // Borrow `ddl` (not move) — this runs inside the retry loop.
+ match sqlx::raw_sql(sqlx::AssertSqlSafe(ddl.as_str()))
+ .execute(&pool)
+ .await
+ {
+ Ok(_) => return Ok(()),
+ Err(e) => {
+ let msg = e.to_string();
+ if msg.contains("Failed to find enough backend") {
+ last_err = Some(e);
+ sleep(Duration::from_secs(2)).await;
+ continue;
+ }
+ return Err(TestBinaryError::FixtureSetup {
+ fixture_type: "DorisOps".to_string(),
+ message: format!("Failed to create table
{database}.{table}: {e}"),
+ });
+ }
+ }
+ }
+ Err(TestBinaryError::FixtureSetup {
+ fixture_type: "DorisOps".to_string(),
+ message: format!(
+ "Failed to create table {database}.{table} after 30 retries
waiting for BE storage report: {}",
+ last_err.map(|e| e.to_string()).unwrap_or_default(),
+ ),
+ })
+ }
+
+ /// Returns true iff `database.table` is registered in Doris's
+ /// `information_schema.tables`. Used by the missing-target-table test to
+ /// assert the connector did NOT silently auto-create on a failed load.
+ async fn table_exists(&self, database: &str, table: &str) -> Result<bool,
TestBinaryError> {
+ let pool = self.pool().await?;
+ let rows = sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
+ "SELECT TABLE_NAME FROM information_schema.tables \
+ WHERE TABLE_SCHEMA = '{database}' AND TABLE_NAME = '{table}'"
+ )))
+ .fetch_all(&pool)
+ .await
+ .map_err(|e| TestBinaryError::InvalidState {
+ message: format!("Failed to query information_schema for
{database}.{table}: {e}"),
+ })?;
+ Ok(!rows.is_empty())
+ }
+
+ async fn count_rows(&self, database: &str, table: &str) -> Result<i64,
TestBinaryError> {
+ let pool = self.pool().await?;
+ // SELECT supports PREPARE in Doris but raw_sql is consistent with
+ // the rest of the fixture and avoids any future surprises.
+ use sqlx::Row;
+ let row = sqlx::raw_sql(sqlx::AssertSqlSafe(format!(
+ "SELECT COUNT(*) AS c FROM {database}.{table}"
+ )))
+ .fetch_one(&pool)
+ .await
+ .map_err(|e| TestBinaryError::InvalidState {
+ message: format!("Failed to count rows in {database}.{table}:
{e}"),
+ })?;
+ // Doris returns COUNT(*) as BIGINT; sqlx decodes that to i64.
+ let count: i64 = row.try_get(0).map_err(|e|
TestBinaryError::InvalidState {
+ message: format!("Failed to read count column: {e}"),
+ })?;
+ Ok(count)
+ }
+
+ async fn wait_for_rows(
+ &self,
+ database: &str,
+ table: &str,
+ expected: i64,
+ ) -> Result<i64, TestBinaryError> {
+ let mut last = 0i64;
+ for _ in 0..DEFAULT_POLL_ATTEMPTS {
+ // Doris is not strongly consistent for very freshly loaded data
+ // until the publish phase completes. Poll until the expected
+ // count is observed (or until we exhaust attempts).
+ if let Ok(c) = self.count_rows(database, table).await {
+ last = c;
+ if c == expected {
+ return Ok(c);
+ }
+ // An overshoot means a duplicate landed (e.g. a label-window
+ // expiry replay). Surface it immediately with a clear message.
+ if c > expected {
+ return Err(TestBinaryError::InvalidState {
+ message: format!(
+ "Expected exactly {expected} rows in
{database}.{table} but observed {c} — a duplicate landed (label-window expiry
replay?)"
+ ),
+ });
+ }
+ }
+ sleep(Duration::from_millis(DEFAULT_POLL_INTERVAL_MS)).await;
+ }
+ Err(TestBinaryError::InvalidState {
+ message: format!(
+ "Expected {expected} rows in {database}.{table} but observed
{last} after {DEFAULT_POLL_ATTEMPTS} polls"
+ ),
+ })
+ }
+}
+
+fn build_connector_envs(fe_url: &str, database: &str) -> HashMap<String,
String> {
+ use integration::harness::seeds;
+
+ HashMap::from([
+ (ENV_SINK_FE_URL.to_string(), fe_url.to_string()),
+ (ENV_SINK_DATABASE.to_string(), database.to_string()),
+ (ENV_SINK_TABLE.to_string(), DEFAULT_TEST_TABLE.to_string()),
+ (ENV_SINK_USERNAME.to_string(), DEFAULT_USER.to_string()),
+ (ENV_SINK_PASSWORD.to_string(), DEFAULT_PASSWORD.to_string()),
+ (ENV_SINK_LABEL_PREFIX.to_string(), "iggy_test".to_string()),
+ (ENV_SINK_BATCH_SIZE.to_string(), "1000".to_string()),
+ (
+ ENV_SINK_STREAMS_0_STREAM.to_string(),
+ seeds::names::STREAM.to_string(),
+ ),
+ (
+ ENV_SINK_STREAMS_0_TOPICS.to_string(),
+ format!("[{}]", seeds::names::TOPIC),
+ ),
+ (ENV_SINK_STREAMS_0_SCHEMA.to_string(), "json".to_string()),
+ (
+ ENV_SINK_STREAMS_0_CONSUMER_GROUP.to_string(),
+ "doris_sink".to_string(),
+ ),
+ (
+ ENV_SINK_PATH.to_string(),
+ "../../target/debug/libiggy_connector_doris_sink".to_string(),
+ ),
+ ])
+}
+
+/// Doris fixture where the test is responsible for creating the table
+/// (e.g. to exercise different DDL or to verify failure when the table
+/// is absent).
+pub struct DorisSinkFixture {
+ container: DorisContainer,
+}
+
+impl DorisOps for DorisSinkFixture {
+ fn container(&self) -> &DorisContainer {
+ &self.container
+ }
+}
+
+#[async_trait]
+impl TestFixture for DorisSinkFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let container = DorisContainer::start().await?;
+ Ok(Self { container })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ build_connector_envs(&self.container.fe_url(),
self.container.database())
+ }
+}
+
+/// Doris fixture where the target table is pre-created during setup.
+/// Mirrors `QuickwitPreCreatedFixture` so tests can rely on the table
+/// being present from the moment the connector starts.
+pub struct DorisSinkPreCreatedFixture {
+ inner: DorisSinkFixture,
+}
+
+impl std::ops::Deref for DorisSinkPreCreatedFixture {
+ type Target = DorisSinkFixture;
+ fn deref(&self) -> &Self::Target {
+ &self.inner
+ }
+}
+
+impl DorisOps for DorisSinkPreCreatedFixture {
+ fn container(&self) -> &DorisContainer {
+ self.inner.container()
+ }
+}
+
+#[async_trait]
+impl TestFixture for DorisSinkPreCreatedFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let inner = DorisSinkFixture::setup().await?;
+ inner
+ .create_table(inner.container.database(), DEFAULT_TEST_TABLE)
+ .await?;
+ Ok(Self { inner })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ self.inner.connectors_runtime_envs()
+ }
+}
+
+/// Pre-created table fixture that additionally tells the connector to use
+/// `max_filter_ratio = 0.5`, so a batch with up to half non-conforming rows
+/// still loads the conforming subset.
+pub struct DorisSinkMaxFilterRatioFixture {
+ inner: DorisSinkPreCreatedFixture,
+}
+
+impl std::ops::Deref for DorisSinkMaxFilterRatioFixture {
+ type Target = DorisSinkPreCreatedFixture;
+ fn deref(&self) -> &Self::Target {
+ &self.inner
+ }
+}
+
+impl DorisOps for DorisSinkMaxFilterRatioFixture {
+ fn container(&self) -> &DorisContainer {
+ self.inner.container()
+ }
+}
+
+#[async_trait]
+impl TestFixture for DorisSinkMaxFilterRatioFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let inner = DorisSinkPreCreatedFixture::setup().await?;
+ Ok(Self { inner })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ let mut envs = self.inner.connectors_runtime_envs();
+ envs.insert(ENV_SINK_MAX_FILTER_RATIO.to_string(), "0.5".to_string());
+ envs
+ }
+}
+
+/// Pre-creates a target table whose schema has an extra column (`calculated`)
+/// that does NOT exist in the JSON payload, and configures the connector with
+/// a `columns` Stream Load header that derives that column from `count`. The
+/// load fails without the `columns` config flowing through correctly, so a
+/// passing test proves the config wiring end-to-end.
+pub struct DorisSinkColumnsMappingFixture {
+ inner: DorisSinkFixture,
+}
+
+impl std::ops::Deref for DorisSinkColumnsMappingFixture {
+ type Target = DorisSinkFixture;
+ fn deref(&self) -> &Self::Target {
+ &self.inner
+ }
+}
+
+impl DorisOps for DorisSinkColumnsMappingFixture {
+ fn container(&self) -> &DorisContainer {
+ self.inner.container()
+ }
+}
+
+#[async_trait]
+impl TestFixture for DorisSinkColumnsMappingFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let inner = DorisSinkFixture::setup().await?;
+ inner
+ .create_table_with_template(
+ inner.container.database(),
+ DEFAULT_TEST_TABLE,
+ TEST_TABLE_WITH_CALCULATED_DDL_TEMPLATE,
+ )
+ .await?;
+ Ok(Self { inner })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ let mut envs = self.inner.connectors_runtime_envs();
+ envs.insert(
+ ENV_SINK_COLUMNS.to_string(),
+ COLUMNS_MAPPING_HEADER.to_string(),
+ );
+ envs
+ }
+}
diff --git a/core/integration/tests/connectors/fixtures/doris/mod.rs
b/core/integration/tests/connectors/fixtures/doris/mod.rs
new file mode 100644
index 000000000..a29563655
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/doris/mod.rs
@@ -0,0 +1,25 @@
+/*
+ * 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 container;
+
+pub use container::{
+ DorisOps, DorisSinkColumnsMappingFixture, DorisSinkFixture,
DorisSinkMaxFilterRatioFixture,
+ DorisSinkPreCreatedFixture,
+};
diff --git a/core/integration/tests/connectors/fixtures/mod.rs
b/core/integration/tests/connectors/fixtures/mod.rs
index a020fa42c..f224c4213 100644
--- a/core/integration/tests/connectors/fixtures/mod.rs
+++ b/core/integration/tests/connectors/fixtures/mod.rs
@@ -18,6 +18,7 @@
*/
mod delta;
+mod doris;
mod elasticsearch;
mod http;
mod iceberg;
@@ -28,6 +29,10 @@ mod quickwit;
mod wiremock;
pub use delta::{DeltaFixture, DeltaS3Fixture};
+pub use doris::{
+ DorisOps, DorisSinkColumnsMappingFixture, DorisSinkFixture,
DorisSinkMaxFilterRatioFixture,
+ DorisSinkPreCreatedFixture,
+};
pub use elasticsearch::{ElasticsearchSinkFixture,
ElasticsearchSourcePreCreatedFixture};
pub use http::{
HttpSinkIndividualFixture, HttpSinkJsonArrayFixture,
HttpSinkMultiTopicFixture,
diff --git a/core/integration/tests/connectors/mod.rs
b/core/integration/tests/connectors/mod.rs
index adf97f1b7..bb4bcc69f 100644
--- a/core/integration/tests/connectors/mod.rs
+++ b/core/integration/tests/connectors/mod.rs
@@ -19,6 +19,7 @@
mod api;
mod delta;
+mod doris;
mod elasticsearch;
mod fixtures;
mod http;