feat(connectors): retry transient Doris Stream Load failures in-request#3574
feat(connectors): retry transient Doris Stream Load failures in-request#3574ryankert01 wants to merge 5 commits into
Conversation
|
Thanks for the PR. It is labeled Slash commands (own line, regular comment) move it around the queue:
See CONTRIBUTING.md for details. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #3574 +/- ##
=============================================
- Coverage 74.34% 41.91% -32.43%
Complexity 950 950
=============================================
Files 1303 1301 -2
Lines 148712 129411 -19301
Branches 124225 104924 -19301
=============================================
- Hits 110562 54246 -56316
- Misses 34673 72404 +37731
+ Partials 3477 2761 -716
🚀 New features to boost your workflow:
|
549e171 to
c8ad134
Compare
hubcio
left a comment
There was a problem hiding this comment.
overall this looks solid, most of below comments are just nits.
one thing not on a changed line so flagging it here: the retry loop is uncancellable by graceful shutdown. the runtime consumer task's tokio::select! only races the shutdown signal against consumer.next() - once a batch is taken, process_messages().await (which runs the sink's consume() -> retry loop) runs to completion with no shutdown arm. so a consume() stuck retrying against a down FE blocks shutdown for the whole budget - up to max_retries * (timeout + backoff) per chunk, tens of seconds to minutes with defaults. only blocks that one consumer's task, and it's not a deadlock. it's runtime-side (not this PR), but this PR stretches the window, so worth a line in operational guidance and maybe a follow-up to make the retry sleep shutdown-aware.
| /// label lets Doris dedupe a prior attempt that actually landed (e.g. a 2xx | ||
| /// whose body we couldn't read). A `PermanentHttpError` returns immediately — | ||
| /// retrying bad data would just hammer the FE. | ||
| async fn load_batch(&self, label: &str, body: Bytes) -> Result<StreamLoadResponse, Error> { |
There was a problem hiding this comment.
the backoff/jitter reuse from iggy_connector_sdk::retry is good. the retry loop itself is now the third hand-written copy of the same skeleton (HttpRetryMiddleware::handle and check_connectivity_with_retry in the sdk both have it). you can't reuse the middleware here - doris signals failure as http 200 + {"Status":"Fail"} which it never inspects - but that's really an argument for the sdk exposing a generic retry_async(op, policy) the three call sites could share. non-blocking, sdk-level follow-up.
There was a problem hiding this comment.
Agreed, filed #3702 to track a shared retry_async(op, policy) in the SDK. Keeping the hand-rolled loop here since the helper touches the two existing SDK call sites and belongs in its own PR.
|
Updated, sorry for the late reply tho. (I was on a conference trip) |
|
/ready |
a30fac5 to
e3f7454
Compare
| /// transport errors, and duplicate labels whose existing Doris job is `RUNNING` | ||
| /// or `CANCELLED`. `PermanentHttpError` (4xx, "Fail", schema/redirect problems, | ||
| /// unparsable body) is never retried — re-PUTing bad data just hammers the FE. | ||
| fn is_transient_error(error: &Error) -> bool { |
There was a problem hiding this comment.
CannotStoreData is one of the two transient markers for the retry loop, but consume() also wraps a local serialize failure in the same variant (the simd_json::to_vec error arm) - that one never goes through load_batch, so it's never retried. harmless since the runtime collapses the return to an i32, just misleading to have the retryable-class variant on a deterministic local failure. a permanent-class variant or a short comment there would make the split cleaner.
There was a problem hiding this comment.
Good catch. I changed local JSON serialization failures to Error::Serialization, so they are no longer represented by the transient CannotStoreData variant.
|
|
||
| /// 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 { |
There was a problem hiding this comment.
floors at 1 but no ceiling. with the readme's own per-chunk bound (N * 6 * timeout + (N-1) * max_retry_delay), max_retries = 100 at the default 30s timeout works out to ~5h of blocking per chunk against a down FE - and per the graceful-shutdown note, close waits for all of it. a warn above some sane cap (say 10) would match the spirit of the retry_delay > max_retry_delay clamp in open().
There was a problem hiding this comment.
Agreed. The sink now emits a startup warning when the effective max_retries exceeds 10, and the shutdown-delay risk is documented in the README and example configurations.
| @@ -575,8 +665,8 @@ fn truncate_for_log(s: &str, max_bytes: usize) -> String { | |||
|
|
|||
| fn parse_stream_load_response(body: &str) -> Result<StreamLoadResponse, Error> { | |||
There was a problem hiding this comment.
asymmetry with the body-read-failure path: a 2xx whose body can't be read is transient (retry re-PUTs, label dedupe reveals the real outcome), but a 2xx that reads fine yet is empty or garbage is permanent. a proxy stripping the body to empty masks a committed load the same way a mid-stream reset does, and the same retry-under-same-label argument applies. the proxy-html / schema-change case is genuinely permanent, so maybe just the empty-body subcase deserves the transient branch. deliberate?
There was a problem hiding this comment.
yeah. update to -> An empty 2xx response is transient and retries the identical request under the same Doris label.
The Doris sink classified transient Stream Load outcomes (5xx/408/429, transport errors, Publish Timeout) as retryable but never acted on them: the runtime commits the consumer offset at poll time before consume() runs and discards its return value, so a transient backend blip silently dropped the batch under at-most-once delivery. consume() now retries a transiently-failed batch in-request, re-PUTing under the same deterministic label so Doris dedupes a prior attempt that actually landed (e.g. a 2xx whose body could not be read). Permanent failures are never retried. Backoff and jitter come from iggy_connector_sdk::retry, bounded by new max_retries/retry_delay/max_retry_delay config (defaults 3/200ms/5s). This shrinks the at-most-once window within a single poll; cross-poll and crash delivery remain a runtime concern, not something a sink can fix. Relates to apache#3215.
43e3460 to
4da2c41
Compare
|
/ready |
|
I reviewed once. Need more time for me to make a notes for my self for each of the error state and handling of the duplicate/non duplicate label. Interesting scenarios. |
Which issue does this PR address?
Relates to #3215
Rationale
Follow-up to the Doris sink (#3215). The connector already classified transient Stream Load outcomes as retryable but had no retry path, so under the runtime's at-most-once delivery a transient backend blip silently dropped the batch.
What changed?
The Doris sink classified transient Stream Load outcomes (
5xx/408/429, transport errors,Publish Timeout) asCannotStoreDatabut never retried them: the runtime commits the consumer offset at poll time beforeconsume()runs and discards its return value, so a transient failure dropped the batch with no replay.consume()now retries a transiently-failed batch in-request via a newload_batch()that wraps send plus status classification, re-PUTing under the same deterministic label so Doris dedupes a prior attempt that actually landed (e.g. a2xxwhose body could not be read). Permanent failures (4xx,Fail, schema/redirect problems) are never retried. Backoff and jitter come fromiggy_connector_sdk::retry, bounded by newmax_retries/retry_delay/max_retry_delayconfig (defaults 3 / 200ms / 5s). This shrinks the at-most-once window within a single poll; cross-poll and crash delivery stay a runtime concern.Local Execution
license-headershook cannot execute on this machine (its script needs bash 4+mapfile; only bash 3.2 is present), buthawkeye checkpasses directly and CI enforces it.markdownlint,taplo,cargo fmt,cargo clippy -p iggy_connector_doris_sink --all-targets -- -D warnings, andcargo test -p iggy_connector_doris_sink(41 tests) all pass.AI Usage
.expect), and permanent-not-retried. Full crate suite, clippy, and doc lint pass locally.