Skip to content

Commit e338bc3

Browse files
committed
feat(connectors): add Apache Doris sink connector (#3112)
Sink connector that writes Iggy messages to Apache Doris via the HTTP Stream Load API. v1 scope: JSON payloads only, HTTP Basic auth, pre-created tables only (no DDL). Behaviour: - Manual 307/308 redirect following (capped at 5) so the Authorization header survives the FE -> BE hop, which reqwest strips by default. - Deterministic per-batch label ({prefix}-{stream}-{topic}-{partition}-{first_offset}-{last_offset}) so replays are deduplicated by Doris within label_keep_max_second. - Response body Status field drives error classification: Success and "Label Already Exists" -> Ok; Publish Timeout -> CannotStoreData (transient); Fail or any unknown status -> PermanentHttpError so the runtime DLQs the batch instead of looping. - Optional columns / where / max_filter_ratio / batch_size / timeout forwarded as Stream Load headers. - Password held as secrecy::SecretString; auth header wrapped in SecretString so Debug derivation never leaks the base64 credential. - Client built in open() with InitError on failure; fe_url validated there too so a bad config fails at startup rather than first batch. Tests: 6 integration tests under core/integration/tests/connectors/doris backed by an apache/doris all-in-one testcontainer (FE HTTP + FE MySQL). Coverage includes happy path, 1k-row bulk, max_filter_ratio skip path, label-replay dedupe, missing-target-table (proves no auto-create), and the columns derived-expression header. The container must bind host:8040 1:1 because the FE 307-redirects to 127.0.0.1:8040; tests are serialized via a 'doris' nextest test-group (max-threads = 1) so concurrent test processes don't race for that port.
1 parent 0928f13 commit e338bc3

17 files changed

Lines changed: 1979 additions & 0 deletions

File tree

.config/nextest.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,18 @@
2121
filter = 'package(integration) and test(cli::system::test_cli_session_scenario::should_be_successful)'
2222
threads-required = "num-cpus"
2323

24+
# Doris testcontainer must bind host:8040 1:1 (FE→BE 307 redirects to
25+
# 127.0.0.1:8040; BE's priority_networks pins the advertise address) and the
26+
# all-in-one image is heavy enough that running it alongside other
27+
# container-backed tests (e.g. elasticsearch) starves their startup timers.
28+
# threads-required = "num-cpus" forces each doris test to run alone, matching
29+
# the existing pattern used elsewhere in this file. The longer slow-timeout
30+
# accounts for cold container boot (~60-90s on Linux CI) plus the test body.
31+
[[profile.default.overrides]]
32+
filter = 'package(integration) and test(/connectors::doris::/)'
33+
threads-required = "num-cpus"
34+
slow-timeout = { period = "60s", terminate-after = 8 }
35+
2436
[profile.default]
2537
slow-timeout = { period = "30s", terminate-after = 4 }
2638

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ members = [
3434
"core/connectors/runtime",
3535
"core/connectors/sdk",
3636
"core/connectors/sinks/delta_sink",
37+
"core/connectors/sinks/doris_sink",
3738
"core/connectors/sinks/elasticsearch_sink",
3839
"core/connectors/sinks/http_sink",
3940
"core/connectors/sinks/iceberg_sink",
@@ -268,6 +269,7 @@ socket2 = "0.6.3"
268269
sqlx = { version = "0.8.6", features = [
269270
"runtime-tokio-rustls",
270271
"postgres",
272+
"mysql",
271273
"chrono",
272274
"uuid",
273275
"json",

core/connectors/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ Each sink should have its own, custom configuration, which is passed along with
7878
7979
### Available Sinks
8080
81+
- **Doris Sink** - loads JSON messages into Apache Doris tables via the Stream Load HTTP API
8182
- **Elasticsearch Sink** - sends messages to Elasticsearch indices
8283
- **Iceberg Sink** - writes data to Apache Iceberg tables via REST catalog
8384
- **PostgreSQL Sink** - stores messages in PostgreSQL database tables
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
type = "sink"
19+
key = "doris"
20+
enabled = true
21+
version = 0
22+
name = "Doris sink"
23+
path = "<BASE_DIR>/target/release/libiggy_connector_doris_sink"
24+
plugin_config_format = "toml"
25+
verbose = false
26+
27+
[[streams]]
28+
stream = "events"
29+
topics = ["doris_events"]
30+
schema = "json"
31+
batch_length = 100
32+
poll_interval = "5ms"
33+
consumer_group = "doris_sink"
34+
35+
[plugin_config]
36+
fe_url = "http://localhost:8030"
37+
database = "iggy_demo"
38+
table = "events"
39+
username = "root"
40+
password = "replace_with_secret"
41+
label_prefix = "iggy"
42+
batch_size = 1000
43+
timeout_secs = 30

core/connectors/sinks/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Sink connectors are responsible for writing data from Iggy streams to external s
88

99
| Sink | Description |
1010
| ---- | ----------- |
11+
| **doris_sink** | Loads JSON messages into Apache Doris tables via the Stream Load HTTP API |
1112
| **elasticsearch_sink** | Sends messages to Elasticsearch indices for full-text search and analytics |
1213
| **iceberg_sink** | Writes data to Apache Iceberg tables via REST catalog with S3/GCS/Azure storage |
1314
| **postgres_sink** | Stores messages in PostgreSQL database tables with configurable schemas |
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
[package]
19+
name = "iggy_connector_doris_sink"
20+
version = "0.1.0"
21+
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."
22+
edition = "2024"
23+
license = "Apache-2.0"
24+
keywords = ["iggy", "messaging", "streaming"]
25+
categories = ["command-line-utilities", "database", "network-programming"]
26+
homepage = "https://iggy.apache.org"
27+
documentation = "https://iggy.apache.org/docs"
28+
repository = "https://github.com/apache/iggy"
29+
readme = "../../README.md"
30+
31+
[lib]
32+
crate-type = ["cdylib", "lib"]
33+
34+
[dependencies]
35+
async-trait = { workspace = true }
36+
base64 = { workspace = true }
37+
iggy_connector_sdk = { workspace = true }
38+
reqwest = { workspace = true }
39+
secrecy = { workspace = true }
40+
serde = { workspace = true }
41+
serde_json = { workspace = true }
42+
simd-json = { workspace = true }
43+
tracing = { workspace = true }
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Apache Doris Sink
2+
3+
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).
4+
5+
## Requirements
6+
7+
- The target Doris **database and table must be pre-created** before enabling the sink. The connector never issues DDL.
8+
- Messages must arrive with `Payload::Json` (i.e. the configured stream schema is `json`). Other payload types are skipped with a warning.
9+
- 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.
10+
11+
## How it works
12+
13+
1. For each batch of messages, the connector serializes the JSON payloads into a JSON array.
14+
2. It computes a deterministic Stream Load `label` of the form `{label_prefix}-{stream}-{topic}-{partition}-{first_offset}-{last_offset}`. Doris dedupes loads by label inside its `label_keep_max_second` window, so a replayed batch (after restart, retry, etc.) is silently absorbed instead of producing duplicates.
15+
3. It `PUT`s the batch to `{fe_url}/api/{database}/{table}/_stream_load` with HTTP Basic auth and the headers `format: json`, `strip_outer_array: true`, `label: <label>`.
16+
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).
17+
5. The HTTP body is parsed as JSON and the `Status` field decides the outcome:
18+
- `Success` → batch accepted.
19+
- `Label Already Exists` → idempotent replay, treated as success.
20+
- `Publish Timeout` or HTTP `5xx` → transient error (`Error::CannotStoreData`); the runtime can retry.
21+
- `Fail` or HTTP `4xx` → permanent error (`Error::PermanentHttpError`); retrying is not useful.
22+
23+
## Configuration
24+
25+
| Field | Required | Default | Description |
26+
| --- | --- | --- | --- |
27+
| `fe_url` | yes || Doris frontend HTTP base URL, e.g. `http://localhost:8030`. |
28+
| `database` | yes || Target database. |
29+
| `table` | yes || Target table. |
30+
| `username` | yes || Doris user with `LOAD_PRIV` on the table. |
31+
| `password` | yes || Doris user password. Stored as a `secrecy::SecretString` and never logged. |
32+
| `label_prefix` | no | `iggy` | Prefix for the deterministic Stream Load label. |
33+
| `batch_size` | no | `1000` | Maximum number of messages per Stream Load request. |
34+
| `timeout_secs` | no | `30` | Per-request HTTP timeout. |
35+
| `max_filter_ratio` | no | unset | Forwarded as the `max_filter_ratio` Stream Load header. |
36+
| `columns` | no | unset | Forwarded as the `columns` Stream Load header. |
37+
| `where` | no | unset | Forwarded as the `where` Stream Load header. |
38+
39+
### Example
40+
41+
```toml
42+
type = "sink"
43+
key = "doris"
44+
enabled = true
45+
version = 0
46+
name = "Doris sink"
47+
path = "target/release/libiggy_connector_doris_sink"
48+
plugin_config_format = "toml"
49+
50+
[[streams]]
51+
stream = "events"
52+
topics = ["doris_events"]
53+
schema = "json"
54+
batch_length = 100
55+
poll_interval = "5ms"
56+
consumer_group = "doris_sink"
57+
58+
[plugin_config]
59+
fe_url = "http://localhost:8030"
60+
database = "iggy_demo"
61+
table = "events"
62+
username = "root"
63+
password = "replace_with_secret"
64+
label_prefix = "iggy"
65+
batch_size = 1000
66+
timeout_secs = 30
67+
```
68+
69+
## Limitations (v1)
70+
71+
- JSON payload only. CSV and raw-text payloads are not supported yet.
72+
- HTTP Basic auth only.
73+
- No automatic table creation.
74+
- 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.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Licensed to the Apache Software Foundation (ASF) under one
2+
# or more contributor license agreements. See the NOTICE file
3+
# distributed with this work for additional information
4+
# regarding copyright ownership. The ASF licenses this file
5+
# to you under the Apache License, Version 2.0 (the
6+
# "License"); you may not use this file except in compliance
7+
# with the License. You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing,
12+
# software distributed under the License is distributed on an
13+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
# KIND, either express or implied. See the License for the
15+
# specific language governing permissions and limitations
16+
# under the License.
17+
18+
type = "sink"
19+
key = "doris"
20+
enabled = true
21+
version = 0
22+
name = "Doris sink"
23+
path = "../../target/release/libiggy_connector_doris_sink"
24+
plugin_config_format = "toml"
25+
verbose = false
26+
27+
[[streams]]
28+
stream = ""
29+
topics = []
30+
schema = "json"
31+
batch_length = 100
32+
poll_interval = "5ms"
33+
consumer_group = "doris_sink"
34+
35+
[plugin_config]
36+
fe_url = "http://localhost:8030"
37+
database = ""
38+
table = ""
39+
username = "root"
40+
password = ""
41+
label_prefix = "iggy"
42+
batch_size = 1000
43+
timeout_secs = 30

0 commit comments

Comments
 (0)