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 75a2fa286 feat(connectors): retry transient Doris Stream Load failures
in-request (#3574)
75a2fa286 is described below
commit 75a2fa2868f96d870d4aa14104af578b2ebeab93
Author: Ryan Huang <[email protected]>
AuthorDate: Mon Aug 3 16:29:44 2026 +0800
feat(connectors): retry transient Doris Stream Load failures in-request
(#3574)
---
.../example_config/connectors/doris_sink.toml | 9 +
core/connectors/sinks/doris_sink/README.md | 31 +-
core/connectors/sinks/doris_sink/config.toml | 9 +
core/connectors/sinks/doris_sink/src/lib.rs | 901 +++++++++++++++++----
.../tests/connectors/doris/doris_sink.rs | 9 +-
5 files changed, 794 insertions(+), 165 deletions(-)
diff --git a/core/connectors/runtime/example_config/connectors/doris_sink.toml
b/core/connectors/runtime/example_config/connectors/doris_sink.toml
index 656b0c2b4..069d0f7d5 100644
--- a/core/connectors/runtime/example_config/connectors/doris_sink.toml
+++ b/core/connectors/runtime/example_config/connectors/doris_sink.toml
@@ -44,6 +44,15 @@ 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"
+# In-request retry for transient Stream Load failures (5xx/408/429, transport
+# errors, and duplicate labels with a RUNNING or CANCELLED existing job).
Retries
+# re-PUT under the same label, which Doris dedupes. max_retries is the total
+# attempt count (0 or 1 disables retries). With N = max(max_retries, 1), a
+# conservative retention bound is N * 6 * timeout + (N - 1) * max_retry_delay.
+# Values above 10 are honored but warn because they can delay graceful
shutdown.
+# max_retries = 3
+# retry_delay = "200ms"
+# max_retry_delay = "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).
diff --git a/core/connectors/sinks/doris_sink/README.md
b/core/connectors/sinks/doris_sink/README.md
index 6da9937be..71333c501 100644
--- a/core/connectors/sinks/doris_sink/README.md
+++ b/core/connectors/sinks/doris_sink/README.md
@@ -13,17 +13,21 @@ The Doris sink connector consumes JSON messages from Iggy
streams and writes the
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")`).
+ - `hash16` is a single 64-bit blake3 hash computed over the *raw*
(un-sanitized), length-prefixed `(label_prefix, table, stream, topic)` tuple.
+ Identities that sanitize to the same string therefore 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`). Length prefixes prevent boundary-shift aliasing (`("ab","c")` ≠
`("a","bc")`). The target table participates because Doris labels are scoped to
a database, not a table.
- 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.
+ - Doris dedupes loads by label inside its `label_keep_max_second` window.
The in-request retry (step 6) re-PUTs a transiently-failed batch under the same
label, so a prior attempt that actually landed (e.g. a `2xx` with a missing or
unreadable body) is absorbed, not doubled. This protects **in-request retry
only**: the runtime commits the offset before `consume()` runs and discards its
return, so a failure outliving the retry budget or a crash mid-load is
**at-most-once**.
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.
+ - `Label Already Exists` with `ExistingJobStatus: FINISHED` → idempotent
replay, treated as success. `RUNNING` or `CANCELLED` is transient and retried;
a missing or unsupported existing-job status is a permanent protocol error.
+ - `Publish Timeout` → the transaction is committed but may not yet be
visible, so it is treated as success and is not retried.
+ - An empty or unreadable `2xx` response body → ambiguous commit outcome,
retried under the same label. A non-empty malformed body remains a permanent
protocol error.
+ - HTTP `5xx`/`408`/`429` → transient error (`Error::CannotStoreData`):
retried in-request up to `max_retries` attempts (exponential backoff + jitter)
under the same label before being surfaced.
+ - `Fail`, any other `4xx`, or a non-empty unparsable response body →
permanent error (`Error::PermanentHttpError`); never retried — re-PUTing bad
data would just hammer the FE.
+6. A *transient* failure (the classifications above, plus a transport-level
error) is retried in-request: the same batch is re-`PUT` under the same label,
up to `max_retries` attempts with backoff and ±20% jitter
(`iggy_connector_sdk::retry`). Since the runtime commits the offset at poll
time, this is the connector's only redelivery path; once the budget is
exhausted the final attempt's error is surfaced and the batch is not retried
again — **at-most-once** across polls.
## Configuration
@@ -38,6 +42,9 @@ The Doris sink connector consumes JSON messages from Iggy
streams and writes the
| `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_retries` | no | `3` | Total Stream Load attempts per batch on a
*transient* failure (`0` or `1` disables retries). Each retry re-PUTs under the
same label, which Doris dedupes. Values above `10` are honored but emit a
startup warning because they can substantially delay graceful shutdown. |
+| `retry_delay` | no | `200ms` | Base backoff before the first retry; doubles
each attempt up to `max_retry_delay`, with ±20% jitter. |
+| `max_retry_delay` | no | `5s` | Strict upper bound on a single retry
backoff, including jitter. |
| `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()`. |
@@ -84,11 +91,17 @@ timeout = "30s"
## 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.
+- **`label_keep_max_second`.** The connector's in-request retry re-PUTs a
transiently-failed batch under the same label, so Doris must retain that label
for at least the connector's full retry budget for the replay to dedupe. The
Doris default is 3 days, which is conservative.
+ If you set this lower on the Doris side, use `N × 6 × timeout + (N - 1) ×
max_retry_delay` as a conservative per-chunk bound, where `N` is the effective
total attempt count (`1` when `max_retries` is `0` or `1`), and leave
operational headroom.
+ Each attempt can issue the initial FE request plus up to five redirected
requests, and each request has its own `timeout`; every inter-attempt delay is
capped at `max_retry_delay`, including jitter. Once a label expires, a retry
re-loads instead of deduping, producing duplicate rows.
+- **Graceful shutdown waits for in-flight retries.** Shutdown is observed
between polls, not during `consume()`. An in-flight `consume()` call must
return, after any remaining chunks and retries, before the plugin can close.
The runtime waits up to five seconds for each consume task; if that deadline
expires, the task handle is dropped and the task continues detached. The
subsequent plugin close blocks while removing the SDK instance until the
in-flight consume call releases its guard.
+ Configure `timeout`, `max_retries`, `max_retry_delay`, and `batch_size` so
the per-chunk bound above, multiplied by the maximum chunks per poll
(`ceil(batch_length / batch_size)`), fits your deployment's stop/restart window.
+- **Label identity changed to include the target table.** Builds containing
this fix generate a different hash than older builds for the same batch. This
prevents two sinks targeting different tables in one database from silently
deduplicating each other.
+ Completed batches are not replayed by an ordinary upgrade, but an old-build
label never dedupes against a replay generated by a new build, even after the
old request finishes. Coordinate upgrades to avoid an in-flight version
boundary, and do not rely on deduplication for a cross-version manual redrive.
- **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).
+- **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 (with its own in-request retry budget for transient failures). If a
chunk still fails after its retries (serialize, HTTP, or status-classification
error), the connector keeps the first error, attempts the remaining chunks, and
returns that error at the end — it does **not** stop at the first such failure.
+ The runtime commits the consumer offset for the whole poll before
`consume()` runs, so a chunk that exhausts its in-request retries is not
replayed across polls; pushing the other chunks through maximizes delivered
data, and the first error is surfaced at the end (logged at `error!` for
observability — the runtime currently discards `consume()`'s return value, so
there is no cross-poll redrive 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
@@ -96,4 +109,4 @@ timeout = "30s"
- 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.
+- In-request retry only. Transient backend failures are retried within a
single `consume()` call (step 6), but the runtime commits the consumer offset
at poll time and discards `consume()`'s return value, so there is no cross-poll
redrive or DLQ — delivery is at-most-once under a crash or a failure that
outlives the retry budget.
diff --git a/core/connectors/sinks/doris_sink/config.toml
b/core/connectors/sinks/doris_sink/config.toml
index 5aaf6b07d..a7a46f392 100644
--- a/core/connectors/sinks/doris_sink/config.toml
+++ b/core/connectors/sinks/doris_sink/config.toml
@@ -44,6 +44,15 @@ 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"
+# In-request retry for transient Stream Load failures (5xx/408/429, transport
+# errors, and duplicate labels with a RUNNING or CANCELLED existing job).
Retries
+# re-PUT under the same label, which Doris dedupes. max_retries is the total
+# attempt count (0 or 1 disables retries). With N = max(max_retries, 1), a
+# conservative retention bound is N * 6 * timeout + (N - 1) * max_retry_delay.
+# Values above 10 are honored but warn because they can delay graceful
shutdown.
+# max_retries = 3
+# retry_delay = "200ms"
+# max_retry_delay = "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).
diff --git a/core/connectors/sinks/doris_sink/src/lib.rs
b/core/connectors/sinks/doris_sink/src/lib.rs
index 3fe0fe2b7..768886fab 100644
--- a/core/connectors/sinks/doris_sink/src/lib.rs
+++ b/core/connectors/sinks/doris_sink/src/lib.rs
@@ -19,13 +19,14 @@ use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use bytes::Bytes;
use humantime::Duration as HumanDuration;
+use iggy_connector_sdk::retry::{exponential_backoff, jitter};
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 serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::time::Duration;
use tracing::{debug, error, info, warn};
@@ -47,8 +48,9 @@ const MAX_REDIRECTS: u8 = 5;
// 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
+// A single 64-bit (16-hex) joint hash over the raw
+// (prefix, table, stream, topic) identity. 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
@@ -58,6 +60,20 @@ const LABEL_HASH_HEX_LEN: usize = 16;
// 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;
+// In-request retry budget for *transient* Stream Load failures (5xx/408/429,
+// transport errors, and duplicate labels whose existing job is RUNNING or
+// CANCELLED).
+// The runtime commits the consumer
+// offset at poll time before consume() runs, so a transient failure we don't
+// retry here is lost — an in-request re-PUT under the same label (which Doris
+// dedupes) is the connector's only redelivery lever. Keep the worst-case
budget
+// well inside Doris's label_keep_max_second so a retry still dedupes.
+const DEFAULT_MAX_RETRIES: u32 = 3;
+// Higher values are still honored, but warn because the retry loop runs inside
+// an uncancellable consume() call and can substantially delay graceful
shutdown.
+const MAX_RETRIES_WARNING_THRESHOLD: u32 = 10;
+const DEFAULT_RETRY_DELAY: &str = "200ms";
+const DEFAULT_MAX_RETRY_DELAY: &str = "5s";
#[derive(Debug)]
pub struct DorisSink {
@@ -85,6 +101,11 @@ struct Connected {
// off `self` instead of threading it through every call.
allow_insecure_redirect: bool,
allowed_redirect_hosts: Option<Vec<String>>,
+ // In-request retry policy for transient Stream Load failures, resolved
once
+ // at `open()`. `max_retries` is the total attempt count (1 = no retry).
+ max_retries: u32,
+ retry_delay: Duration,
+ max_retry_delay: Duration,
}
#[derive(Debug, Deserialize)]
@@ -107,6 +128,20 @@ pub struct DorisSinkConfig {
/// cross-region or cold-start FEs that are slow to accept the connection.
pub connect_timeout: Option<String>,
pub batch_size: Option<u32>,
+ /// Total number of Stream Load attempts per batch on a *transient* failure
+ /// (HTTP 5xx/408/429, a transport error, or a duplicate label whose
existing
+ /// job is `RUNNING` or `CANCELLED`). `0` or `1` disables retries. Each
retry
+ /// re-PUTs under the same label — which Doris dedupes — so an ambiguous
+ /// success (e.g. a 2xx with a missing or unreadable body) is absorbed
rather
+ /// than doubled. Default 3. Values above 10 are honored but emit a startup
+ /// warning because they can substantially delay graceful shutdown.
+ pub max_retries: Option<u32>,
+ /// Base backoff before the first retry, as a human-readable duration (e.g.
+ /// "200ms"). Doubles each attempt up to `max_retry_delay`, with ±20%
jitter.
+ pub retry_delay: Option<String>,
+ /// Strict upper bound on a single retry backoff, including jitter, as a
+ /// human-readable duration (e.g. "5s").
+ pub max_retry_delay: Option<String>,
/// 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
@@ -135,6 +170,9 @@ struct StreamLoadResponse {
#[serde(rename = "NumberFilteredRows")]
#[serde(default)]
number_filtered_rows: u64,
+ #[serde(rename = "ExistingJobStatus")]
+ #[serde(default)]
+ existing_job_status: Option<String>,
}
impl DorisSink {
@@ -160,8 +198,8 @@ impl DorisSink {
}
fn build_client(&self) -> Result<reqwest::Client, Error> {
- let timeout = parse_duration(self.config.timeout.as_deref(),
DEFAULT_TIMEOUT);
- let connect_timeout = parse_duration(
+ let timeout = parse_request_duration(self.config.timeout.as_deref(),
DEFAULT_TIMEOUT);
+ let connect_timeout = parse_request_duration(
self.config.connect_timeout.as_deref(),
DEFAULT_CONNECT_TIMEOUT,
);
@@ -177,15 +215,10 @@ impl DorisSink {
async fn send_stream_load(
&self,
+ connected: &Connected,
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();
@@ -269,8 +302,8 @@ impl DorisSink {
// 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.
+ // Doris's dedupe reveals the real outcome instead of
+ // surfacing a committed load as a permanent failure.
warn!(
"Doris sink ID {} failed to read 2xx response body:
{e}; treating as retryable",
self.id
@@ -283,7 +316,8 @@ impl DorisSink {
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).
+ // lets the HTTP status mapping below determine whether the
+ // outcome is transient or permanent.
warn!(
"Doris sink ID {} failed to read response body: {e}",
self.id
@@ -299,7 +333,8 @@ impl DorisSink {
self.id
);
error!("{msg}");
- // 408/429 are 4xx but transient — retry them, don't DLQ.
+ // 408/429 are 4xx but transient, so include them in the
bounded
+ // in-request retry path.
return Err(match status {
StatusCode::REQUEST_TIMEOUT |
StatusCode::TOO_MANY_REQUESTS => {
Error::CannotStoreData(msg)
@@ -312,6 +347,54 @@ impl DorisSink {
return parse_stream_load_response(&response_text);
}
}
+
+ /// Load one batch with bounded in-request retry. `send_stream_load`
performs
+ /// a single full FE -> BE attempt; this wraps it (plus status
+ /// classification) so a *transient* failure re-PUTs under the same
`label`.
+ /// The runtime commits the consumer offset before consume() runs, so this
is
+ /// the connector's only redelivery path on a transient outage; the shared
+ /// label lets Doris dedupe a prior attempt that actually landed (e.g. a
2xx
+ /// with a missing or unreadable body). A `PermanentHttpError` returns
+ /// immediately — retrying bad data would just hammer the FE.
+ async fn load_batch(&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
+ ))
+ })?;
+
+ let mut attempt = 0u32;
+ loop {
+ let error = match self
+ .send_stream_load(connected, label, body.clone())
+ .await
+ .and_then(|response| classify_status(self.id,
&response).map(|()| response))
+ {
+ Ok(response) => return Ok(response),
+ Err(error) => error,
+ };
+
+ attempt += 1;
+ if attempt >= connected.max_retries || !is_transient_error(&error)
{
+ return Err(error);
+ }
+
+ // `attempt` counts completed attempts. Subtract one so the first
+ // retry waits exactly the configured base delay (base * 2^0).
+ let delay = jitter(exponential_backoff(
+ connected.retry_delay,
+ attempt - 1,
+ connected.max_retry_delay,
+ ))
+ .min(connected.max_retry_delay);
+ warn!(
+ "Doris sink ID {} transient Stream Load failure on attempt
{attempt}/{} (label={label}): {error}; retrying in {delay:?}",
+ self.id, connected.max_retries
+ );
+ tokio::time::sleep(delay).await;
+ }
+ }
}
impl Connected {
@@ -428,26 +511,28 @@ fn split_host_port(entry: &str) -> (&str, Option<u16>) {
}
/// 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.
+/// a warning when malformed. Zero is valid for retry backoff configuration.
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)
+ HumanDuration::from_str(raw)
.map(|d| *d)
.unwrap_or_else(|e| {
warn!("Invalid duration '{raw}': {e}, using default '{default}'");
- fallback()
- });
+ *HumanDuration::from_str(default).expect("default duration must be
valid")
+ })
+}
+
+/// Parse a reqwest request timeout. Unlike retry delays, a zero request
timeout
+/// is degenerate because every request expires immediately.
+fn parse_request_duration(input: Option<&str>, default: &str) -> Duration {
+ let raw = input.unwrap_or(default);
+ let parsed = parse_duration(input, default);
if parsed.is_zero() {
warn!(
"Duration '{raw}' is zero, which would time out every request
immediately; \
using default '{default}'"
);
- return fallback();
+ return *HumanDuration::from_str(default).expect("default duration must
be valid");
}
parsed
}
@@ -484,6 +569,16 @@ fn effective_batch_size(configured: Option<u32>) -> usize {
configured.unwrap_or(DEFAULT_BATCH_SIZE).max(1) as usize
}
+/// Total Stream Load attempts per batch. An unset value uses the default;
+/// configured `0` or `1` both mean one attempt with no retry.
+fn effective_max_retries(configured: Option<u32>) -> u32 {
+ configured.unwrap_or(DEFAULT_MAX_RETRIES).max(1)
+}
+
+fn should_warn_for_retry_count(max_retries: u32) -> bool {
+ max_retries > MAX_RETRIES_WARNING_THRESHOLD
+}
+
/// 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
@@ -512,16 +607,17 @@ fn sanitize_segment(value: &str, max_len: usize) ->
String {
.collect()
}
-/// A single blake3 fingerprint over the *raw* (unsanitized) `prefix`,
`stream`,
-/// and `topic`, truncated to `LABEL_HASH_HEX_LEN` hex chars. This
disambiguates
+/// A single blake3 fingerprint over the *raw* (unsanitized) `prefix`, target
+/// table, `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 {
+/// database-scoped label dedupe. Inputs are length-prefixed so distinct tuples
+/// cannot alias into one digest.
+fn identity_hash(prefix: &str, table: &str, stream: &str, topic: &str) ->
String {
let mut hasher = blake3::Hasher::new();
- for part in [prefix, stream, topic] {
+ for part in [prefix, table, stream, topic] {
hasher.update(&(part.len() as u64).to_le_bytes());
hasher.update(part.as_bytes());
}
@@ -533,14 +629,17 @@ fn identity_hash(prefix: &str, stream: &str, topic: &str)
-> String {
///
`{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.
+/// case 120), and the joint `hash16` over the raw source and target identity
+/// keeps labels distinct even when the sanitized segments collide. The target
+/// table must participate because Doris labels are scoped to a database rather
+/// than to an individual table.
///
/// `#[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,
+ table: &str,
stream: &str,
topic: &str,
partition_id: u32,
@@ -552,7 +651,7 @@ pub fn build_label(
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),
+ identity_hash(prefix, table, stream, topic),
partition_id,
first_offset,
last_offset,
@@ -573,10 +672,28 @@ fn truncate_for_log(s: &str, max_bytes: usize) -> String {
format!("{}...(truncated, total {} bytes)", &s[..end], s.len())
}
+fn serialize_json_batch<T>(batch: &T) -> Result<Bytes, Error>
+where
+ T: Serialize + ?Sized,
+{
+ simd_json::to_vec(batch)
+ .map(Bytes::from)
+ .map_err(|e| Error::Serialization(format!("Failed to serialize batch
for Doris: {e}")))
+}
+
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.
+ if body.is_empty() {
+ // A readable but empty 2xx body leaves the commit outcome ambiguous in
+ // exactly the same way as a body-read failure. Retrying the identical
+ // request under the same label lets Doris reveal or dedupe the
outcome.
+ return Err(Error::CannotStoreData(
+ "Doris Stream Load returned an empty 2xx response
body".to_string(),
+ ));
+ }
+
+ // A non-empty, unparsable 2xx body (Doris bug, proxy-injected HTML, future
+ // schema change) isn't cured by retrying the same bytes, so classify it as
+ // permanent and surface it without spending the retry budget.
serde_json::from_str(body).map_err(|e| {
Error::PermanentHttpError(format!(
"Failed to parse Doris stream load response: {e}. Body: {}",
@@ -599,31 +716,59 @@ fn validate_identifier(name: &str, field: &str, id: u32)
-> Result<(), Error> {
Ok(())
}
-fn classify_status(response: &StreamLoadResponse) -> Result<(), Error> {
+/// Transient Stream Load failures worth an in-request retry: HTTP 5xx/408/429,
+/// transport errors, and duplicate labels whose existing Doris job is
`RUNNING`
+/// or `CANCELLED`. `PermanentHttpError` (4xx, "Fail", schema/redirect
problems,
+/// non-empty unparsable body) is never retried — re-PUTing bad data just
hammers
+/// the FE.
+fn is_transient_error(error: &Error) -> bool {
+ matches!(
+ error,
+ Error::CannotStoreData(_) | Error::HttpRequestFailed(_)
+ )
+}
+
+fn classify_status(id: u32, 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
+ "Label Already Exists" => match
response.existing_job_status.as_deref() {
+ Some("FINISHED") => {
+ info!(
+ "Doris sink ID {id} confirmed duplicate label belongs to a
FINISHED job; treating as success"
+ );
+ Ok(())
+ }
+ Some(existing_status @ ("RUNNING" | "CANCELLED")) => {
+ Err(Error::CannotStoreData(format!(
+ "Doris sink ID {id} found duplicate label with retryable
existing job status '{}': {}",
+ existing_status, response.message
+ )))
+ }
+ Some(existing_status) => Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {id} found duplicate label with unsupported
existing job status '{existing_status}': {}",
+ response.message
+ ))),
+ None => Err(Error::PermanentHttpError(format!(
+ "Doris sink ID {id} found duplicate label without
ExistingJobStatus: {}",
+ response.message
+ ))),
+ },
+ "Publish Timeout" => {
+ warn!(
+ "Doris sink ID {id} stream load committed but publish
visibility timed out; treating as success: {}",
+ response.message
);
Ok(())
}
- "Publish Timeout" => Err(Error::CannotStoreData(format!(
- "Doris stream load Publish Timeout: {}",
- response.message
- ))),
"Fail" => Err(Error::PermanentHttpError(format!(
- "Doris stream load failed: {}",
+ "Doris sink ID {id} 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.
+ // failure (e.g. a future Doris error variant) beats silently retrying
it
+ // against the FE until the in-request budget is exhausted.
other => Err(Error::PermanentHttpError(format!(
- "Doris stream load returned unexpected status '{other}': {}",
+ "Doris sink ID {id} stream load returned unexpected status
'{other}': {}",
response.message
))),
}
@@ -710,6 +855,30 @@ impl Sink for DorisSink {
None => None,
};
+ let retry_delay = parse_duration(self.config.retry_delay.as_deref(),
DEFAULT_RETRY_DELAY);
+ let max_retry_delay = parse_duration(
+ self.config.max_retry_delay.as_deref(),
+ DEFAULT_MAX_RETRY_DELAY,
+ );
+ let max_retries = effective_max_retries(self.config.max_retries);
+ if should_warn_for_retry_count(max_retries) {
+ warn!(
+ "Doris sink ID {} configured max_retries={max_retries}, above
the warning threshold {MAX_RETRIES_WARNING_THRESHOLD}; the value is honored,
but an unavailable FE can keep each chunk in consume() for tens of minutes or
hours and delay graceful shutdown",
+ self.id
+ );
+ }
+ // `exponential_backoff` already caps at the max, but a base above the
cap
+ // is a config mistake worth surfacing rather than silently flattening.
+ let (retry_delay, max_retry_delay) = if retry_delay > max_retry_delay {
+ warn!(
+ "Doris sink ID {}: retry_delay ({retry_delay:?}) exceeds
max_retry_delay ({max_retry_delay:?}); clamping base to the cap",
+ self.id
+ );
+ (max_retry_delay, max_retry_delay)
+ } else {
+ (retry_delay, max_retry_delay)
+ };
+
self.connected = Some(Connected {
client: self.build_client()?,
base_url,
@@ -718,6 +887,9 @@ impl Sink for DorisSink {
where_header,
allow_insecure_redirect:
self.config.allow_insecure_redirect.unwrap_or(false),
allowed_redirect_hosts: self.config.allowed_redirect_hosts.clone(),
+ max_retries,
+ retry_delay,
+ max_retry_delay,
});
info!(
@@ -787,19 +959,21 @@ impl Sink for DorisSink {
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}"
- )));
+ let body = match serialize_json_batch(&json_values) {
+ Ok(body) => body,
+ Err(error) => {
+ error!(
+ "Doris sink ID {} failed to serialize batch: {error}",
+ self.id
+ );
+ first_error.get_or_insert(error);
continue;
}
};
let label = build_label(
label_prefix,
+ &self.config.table,
&topic_metadata.stream,
&topic_metadata.topic,
messages_metadata.partition_id,
@@ -807,39 +981,36 @@ impl Sink for DorisSink {
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);
+ match self.load_batch(&label, body).await {
+ Ok(response) => {
+ 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 {} HTTP failed: {e}", self.id);
- first_error.get_or_insert(e);
+ }
+ Err(error) => {
+ error!(
+ "Doris sink ID {} batch failed (label={label}):
{error}",
+ self.id
+ );
+ first_error.get_or_insert(error);
}
}
}
@@ -876,6 +1047,19 @@ mod tests {
batch_size: None,
allow_insecure_redirect: None,
allowed_redirect_hosts: None,
+ max_retries: None,
+ retry_delay: None,
+ max_retry_delay: None,
+ }
+ }
+
+ fn stream_load_response(status: &str, existing_job_status: Option<&str>)
-> StreamLoadResponse {
+ StreamLoadResponse {
+ status: status.into(),
+ message: String::new(),
+ number_loaded_rows: 0,
+ number_filtered_rows: 0,
+ existing_job_status: existing_job_status.map(String::from),
}
}
@@ -922,8 +1106,8 @@ mod tests {
#[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);
+ let a = build_label("iggy", "test_tbl", "events", "orders", 7, 100,
199);
+ let b = build_label("iggy", "test_tbl", "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();
@@ -940,7 +1124,7 @@ mod tests {
#[test]
fn label_sanitizes_illegal_chars() {
- let label = build_label("iggy", "events.v1", "orders/inbound", 0, 0,
0);
+ let label = build_label("iggy", "test_tbl", "events.v1",
"orders/inbound", 0, 0, 0);
// dots and slashes are not allowed in Doris labels.
assert!(!label.contains('.'));
assert!(!label.contains('/'));
@@ -953,8 +1137,8 @@ mod tests {
// 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),
+ build_label("iggy", "test_tbl", "events.v1", "orders", 0, 0, 0),
+ build_label("iggy", "test_tbl", "events_v1", "orders", 0, 0, 0),
"labels must NOT collide for names that sanitize to the same
string"
);
}
@@ -965,8 +1149,24 @@ mod tests {
// 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);
+ let a = build_label(
+ "prod_events_us_east_1",
+ "test_tbl",
+ "events",
+ "orders",
+ 0,
+ 0,
+ 0,
+ );
+ let b = build_label(
+ "prod_events_us_east_2",
+ "test_tbl",
+ "events",
+ "orders",
+ 0,
+ 0,
+ 0,
+ );
// Precondition: the sanitized prefix segments collide (both truncate
to
// the same 16 chars).
assert_eq!(
@@ -982,25 +1182,36 @@ mod tests {
);
}
+ #[test]
+ fn label_disambiguates_target_tables_in_same_database() {
+ let first = build_label("iggy", "orders", "events", "created", 0, 0,
99);
+ let second = build_label("iggy", "orders_archive", "events",
"created", 0, 0, 99);
+
+ assert_ne!(
+ first, second,
+ "Doris labels are database-scoped, so the target table must affect
the label"
+ );
+ }
+
#[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
+ // produce the same digest: distinct source/target identity tuples 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")
+ identity_hash("iggy", "test_tbl", "ab", "c"),
+ identity_hash("iggy", "test_tbl", "a", "bc")
);
assert_ne!(
- identity_hash("iggy", "events", "orders"),
- identity_hash("iggy", "event", "sorders")
+ identity_hash("iggy", "test_tbl", "events", "orders"),
+ identity_hash("iggy", "test_tbl", "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")
+ identity_hash("ab", "test_tbl", "c", "topic"),
+ identity_hash("a", "test_tbl", "bc", "topic")
);
}
@@ -1012,7 +1223,15 @@ mod tests {
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);
+ let label = build_label(
+ &prefix,
+ "test_tbl",
+ &stream,
+ &topic,
+ u32::MAX,
+ u64::MAX,
+ u64::MAX,
+ );
assert!(
label.len() <= 128,
"label exceeds Doris's 128-char cap: {} chars: {label}",
@@ -1027,56 +1246,87 @@ mod tests {
assert_eq!(effective_batch_size(Some(500)), 500);
}
+ #[test]
+ fn effective_max_retries_uses_default_and_floors_at_one() {
+ assert_eq!(effective_max_retries(None), DEFAULT_MAX_RETRIES);
+ assert_eq!(effective_max_retries(Some(0)), 1);
+ assert_eq!(effective_max_retries(Some(1)), 1);
+ assert_eq!(effective_max_retries(Some(5)), 5);
+ assert_eq!(
+ effective_max_retries(Some(MAX_RETRIES_WARNING_THRESHOLD + 1)),
+ MAX_RETRIES_WARNING_THRESHOLD + 1
+ );
+ }
+
+ #[test]
+ fn retry_count_warning_starts_above_threshold() {
+ assert!(!should_warn_for_retry_count(MAX_RETRIES_WARNING_THRESHOLD));
+ assert!(should_warn_for_retry_count(
+ MAX_RETRIES_WARNING_THRESHOLD + 1
+ ));
+ }
+
#[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());
+ let mut response = stream_load_response("Success", None);
+ response.number_loaded_rows = 10;
+ assert!(classify_status(1, &response).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());
+ fn classify_finished_duplicate_returns_ok() {
+ let response = stream_load_response("Label Already Exists",
Some("FINISHED"));
+ assert!(classify_status(1, &response).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(_)
- ));
+ fn classify_running_or_cancelled_duplicate_is_transient() {
+ for existing_status in ["RUNNING", "CANCELLED"] {
+ let response = stream_load_response("Label Already Exists",
Some(existing_status));
+ assert!(matches!(
+ classify_status(1, &response),
+ Err(Error::CannotStoreData(_))
+ ));
+ }
+ }
+
+ #[test]
+ fn classify_unconfirmed_duplicate_is_permanent() {
+ for existing_status in [None, Some(""), Some("PRECOMMITTED"),
Some("UNKNOWN")] {
+ let response = stream_load_response("Label Already Exists",
existing_status);
+ assert!(matches!(
+ classify_status(1, &response),
+ Err(Error::PermanentHttpError(_))
+ ));
+ }
+ }
+
+ #[test]
+ fn classify_publish_timeout_returns_ok() {
+ let mut response = stream_load_response("Publish Timeout", None);
+ response.message = "publish visibility delayed".into();
+ assert!(classify_status(1, &response).is_ok());
}
#[test]
fn classify_fail_is_permanent() {
- let r = StreamLoadResponse {
- status: "Fail".into(),
- message: "schema mismatch".into(),
- number_loaded_rows: 0,
- number_filtered_rows: 0,
- };
+ let mut response = stream_load_response("Fail", None);
+ response.message = "schema mismatch".into();
assert!(matches!(
- classify_status(&r).unwrap_err(),
+ classify_status(1, &response).unwrap_err(),
Error::PermanentHttpError(_)
));
}
+ #[test]
+ fn classify_unknown_status_is_permanent() {
+ let response = stream_load_response("Future Doris Status", None);
+ assert!(matches!(
+ classify_status(1, &response),
+ Err(Error::PermanentHttpError(_))
+ ));
+ }
+
#[test]
fn parse_stream_load_response_handles_minimal_json() {
let body = r#"{"Status":"Success"}"#;
@@ -1086,9 +1336,17 @@ mod tests {
}
#[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.
+ fn parse_stream_load_response_treats_empty_body_as_transient() {
+ assert!(matches!(
+ parse_stream_load_response("").unwrap_err(),
+ Error::CannotStoreData(_)
+ ));
+ }
+
+ #[test]
+ fn parse_stream_load_response_rejects_nonempty_garbage_as_permanent() {
+ // An unparsable body must surface as PermanentHttpError instead of
+ // retrying the same garbage for the whole in-request budget.
let body = "not json";
assert!(matches!(
parse_stream_load_response(body).unwrap_err(),
@@ -1096,6 +1354,15 @@ mod tests {
));
}
+ #[test]
+ fn serialize_json_batch_maps_local_failure_to_serialization_error() {
+ let invalid_json_map = std::collections::BTreeMap::from([(true, 1)]);
+ let error = serialize_json_batch(&invalid_json_map).unwrap_err();
+
+ assert!(matches!(&error, Error::Serialization(_)));
+ assert!(!is_transient_error(&error));
+ }
+
#[test]
fn validate_identifier_rejects_path_traversal() {
assert!(validate_identifier("../admin", "database", 1).is_err());
@@ -1119,18 +1386,22 @@ mod tests {
}
#[test]
- fn parse_duration_parses_and_falls_back() {
+ fn parse_duration_parses_zero_and_falls_back_for_invalid_input() {
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));
+ assert_eq!(parse_duration(Some("0ms"), "5s"), Duration::ZERO);
+ }
+
+ #[test]
+ fn parse_request_duration_rejects_zero() {
+ assert_eq!(
+ parse_request_duration(Some("0s"), "30s"),
+ Duration::from_secs(30)
+ );
}
#[tokio::test]
@@ -1163,6 +1434,10 @@ mod tests {
reqwest::Url::parse(s).unwrap()
}
+ fn opened_connection(sink: &DorisSink) -> &Connected {
+ sink.connected.as_ref().expect("sink should be open")
+ }
+
/// Build a `Connected` for redirect-validation tests: a throwaway client
and
/// no precomputed headers, with the redirect policy under test.
fn connected(
@@ -1178,6 +1453,9 @@ mod tests {
where_header: None,
allow_insecure_redirect: allow_insecure,
allowed_redirect_hosts: allowed_hosts,
+ max_retries: DEFAULT_MAX_RETRIES,
+ retry_delay: Duration::from_millis(1),
+ max_retry_delay: Duration::from_millis(5),
}
}
@@ -1443,7 +1721,11 @@ mod tests {
sink.open().await.expect("open should succeed");
let result = sink
- .send_stream_load("iggy-test-label",
Bytes::from_static(b"[{\"a\":1}]"))
+ .send_stream_load(
+ opened_connection(&sink),
+ "iggy-test-label",
+ Bytes::from_static(b"[{\"a\":1}]"),
+ )
.await;
assert!(
@@ -1476,7 +1758,11 @@ mod tests {
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}]"))
+ .send_stream_load(
+ opened_connection(&sink),
+ "iggy-test-label",
+ Bytes::from_static(b"[{\"a\":1}]"),
+ )
.await;
assert!(
@@ -1504,7 +1790,11 @@ mod tests {
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}]"))
+ .send_stream_load(
+ opened_connection(&sink),
+ "iggy-test-label",
+ Bytes::from_static(b"[{\"a\":1}]"),
+ )
.await;
assert!(
@@ -1533,7 +1823,11 @@ mod tests {
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}]"))
+ .send_stream_load(
+ opened_connection(&sink),
+ "iggy-test-label",
+ Bytes::from_static(b"[{\"a\":1}]"),
+ )
.await;
assert!(
@@ -1541,4 +1835,307 @@ mod tests {
"expected PermanentHttpError on relative Location, got {result:?}",
);
}
+
+ /// A transient failure (HTTP 503) is retried through the public `consume`
+ /// path. Both mocks match the generated label and serialized body, proving
+ /// the retry re-PUTs the same batch under the same idempotency key.
+ #[tokio::test]
+ async fn transient_failure_is_retried_then_succeeds() {
+ use wiremock::matchers::{body_json, header, method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+ cfg.max_retries = Some(3);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+ let expected_label = build_label("iggy", "test_tbl", "events",
"orders", 0, 0, 0);
+ let expected_body = serde_json::json!([{"k": 1}]);
+
+ // wiremock serves the first matching mock in mount order, so mount the
+ // single-shot 503 first: it serves attempt 1, then — capped at one
+ // response — stops matching, and attempt 2 falls through to the
success.
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .and(header("label", expected_label.as_str()))
+ .and(body_json(expected_body.clone()))
+ .respond_with(ResponseTemplate::new(503))
+ .up_to_n_times(1)
+ .expect(1)
+ .mount(&server)
+ .await;
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .and(header("label", expected_label.as_str()))
+ .and(body_json(expected_body))
+ .respond_with(
+ ResponseTemplate::new(200)
+ .set_body_json(serde_json::json!({"Status": "Success",
"NumberLoadedRows": 1})),
+ )
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .consume(&topic_meta(), messages_meta(), vec![json_msg(0)])
+ .await;
+
+ assert!(
+ result.is_ok(),
+ "expected consume() to succeed after one retry, got {result:?}",
+ );
+ }
+
+ /// A readable but empty 2xx response leaves the commit outcome unknown.
+ /// Retry the identical request and let Doris's label state confirm that
the
+ /// first attempt finished, without loading the batch twice.
+ #[test]
+ fn empty_success_body_is_retried_under_same_label() {
+ use wiremock::matchers::{body_json, header, method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let runtime = tokio::runtime::Runtime::new().expect("test runtime
should build");
+ runtime.block_on(async {
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+ cfg.max_retries = Some(3);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+
+ let label = "iggy-test-label";
+ let body = serde_json::json!([{"a": 1}]);
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .and(header("label", label))
+ .and(body_json(body.clone()))
+ .respond_with(ResponseTemplate::new(200))
+ .up_to_n_times(1)
+ .expect(1)
+ .mount(&server)
+ .await;
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .and(header("label", label))
+ .and(body_json(body))
+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "Status": "Label Already Exists",
+ "ExistingJobStatus": "FINISHED",
+ "Message": "job finished",
+ })))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .load_batch(label, Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Ok(response) if
response.existing_job_status.as_deref() == Some("FINISHED")),
+ "expected the retry to confirm the first attempt, got
{result:?}",
+ );
+ });
+ }
+
+ /// A non-empty malformed 2xx body is a protocol failure, not an ambiguous
+ /// missing response. It must remain permanent and consume only one
attempt.
+ #[test]
+ fn nonempty_malformed_success_body_is_not_retried() {
+ use wiremock::matchers::{method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let runtime = tokio::runtime::Runtime::new().expect("test runtime
should build");
+ runtime.block_on(async {
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+ cfg.max_retries = Some(3);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(
+ ResponseTemplate::new(200).set_body_string("<html>proxy
error</html>"),
+ )
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .load_batch("iggy-test-label",
Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Err(Error::PermanentHttpError(_))),
+ "expected non-empty malformed response to stay permanent, got
{result:?}",
+ );
+ });
+ }
+
+ /// An unfinished duplicate label means Doris may still be completing an
+ /// earlier ambiguous attempt. Retry the same request until Doris confirms
+ /// that job is FINISHED, then accept it without issuing a third request.
+ #[tokio::test]
+ async fn running_duplicate_is_retried_until_finished() {
+ use wiremock::matchers::{body_json, header, method, path};
+ use wiremock::{Mock, MockServer, ResponseTemplate};
+
+ let server = MockServer::start().await;
+ let mut cfg = make_config();
+ cfg.fe_url = server.uri();
+ cfg.max_retries = Some(3);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+
+ let label = "iggy-test-label";
+ let body = serde_json::json!([{"a": 1}]);
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .and(header("label", label))
+ .and(body_json(body.clone()))
+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "Status": "Label Already Exists",
+ "ExistingJobStatus": "RUNNING",
+ "Message": "job is still running",
+ })))
+ .up_to_n_times(1)
+ .expect(1)
+ .mount(&server)
+ .await;
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .and(header("label", label))
+ .and(body_json(body))
+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "Status": "Label Already Exists",
+ "ExistingJobStatus": "FINISHED",
+ "Message": "job finished",
+ })))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .load_batch(label, Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Ok(response) if
response.existing_job_status.as_deref() == Some("FINISHED")),
+ "expected FINISHED duplicate after one retry, got {result:?}",
+ );
+ }
+
+ /// Doris documents Publish Timeout as a committed transaction whose data
+ /// may not yet be visible. Treat it as success and do not retry the
payload.
+ #[tokio::test]
+ async fn publish_timeout_is_not_retried() {
+ 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();
+ cfg.max_retries = Some(3);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+ "Status": "Publish Timeout",
+ "Message": "transaction committed; publish is delayed",
+ })))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .load_batch("iggy-test-label", Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Ok(response) if response.status == "Publish
Timeout"),
+ "expected Publish Timeout to be accepted without a retry, got
{result:?}",
+ );
+ }
+
+ /// When every attempt fails transiently, the budget is exhausted and the
+ /// last transient error is surfaced. `.expect(2)` pins the attempt count
to
+ /// exactly `max_retries` (1 initial + 1 retry) — no over- or under-retry.
+ #[tokio::test]
+ async fn transient_failure_exhausts_retries_and_returns_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();
+ cfg.max_retries = Some(2);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(ResponseTemplate::new(503))
+ .expect(2)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .load_batch("iggy-test-label", Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Err(Error::CannotStoreData(_))),
+ "expected CannotStoreData after exhausting retries, got
{result:?}",
+ );
+ }
+
+ /// A permanent failure (HTTP 400) returns on the first attempt with no
+ /// retry, even with `max_retries` high. `.expect(1)` pins it to one
attempt.
+ #[tokio::test]
+ async fn permanent_failure_is_not_retried() {
+ 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();
+ cfg.max_retries = Some(5);
+ cfg.retry_delay = Some("1ms".into());
+ cfg.max_retry_delay = Some("5ms".into());
+
+ Mock::given(method("PUT"))
+ .and(path("/api/test_db/test_tbl/_stream_load"))
+ .respond_with(ResponseTemplate::new(400))
+ .expect(1)
+ .mount(&server)
+ .await;
+
+ let mut sink = DorisSink::new(1, cfg);
+ sink.open().await.expect("open should succeed");
+ let result = sink
+ .load_batch("iggy-test-label", Bytes::from_static(b"[{\"a\":1}]"))
+ .await;
+
+ assert!(
+ matches!(&result, Err(Error::PermanentHttpError(_))),
+ "expected PermanentHttpError with no retry, got {result:?}",
+ );
+ }
}
diff --git a/core/integration/tests/connectors/doris/doris_sink.rs
b/core/integration/tests/connectors/doris/doris_sink.rs
index ec9798c8a..ae96f29f1 100644
--- a/core/integration/tests/connectors/doris/doris_sink.rs
+++ b/core/integration/tests/connectors/doris/doris_sink.rs
@@ -232,10 +232,10 @@ async fn given_replayed_label_should_dedupe(harness:
&TestHarness, fixture: Dori
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
+ // connector generates a deterministic Stream Load label per (target table,
+ // 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.
//
@@ -270,6 +270,7 @@ async fn given_replayed_label_should_dedupe(harness:
&TestHarness, fixture: Dori
for last in first..=last_offset {
let candidate = iggy_connector_doris_sink::build_label(
"iggy_test",
+ TEST_TABLE,
seeds::names::STREAM,
seeds::names::TOPIC,
0,