fix(sdk): recover from InvalidOffset by falling back to first available offset - #3525
fix(sdk): recover from InvalidOffset by falling back to first available offset#3525mfyuce wants to merge 4 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. |
- AGENTS.md: 104→75 lines. Removed redundant repo structure (derivable by ls), collapsed principles to iggy-specific rules only, merged Jenkins/QW infra into Infra section, updated handover block. - TODO.md: replaced stale checked items with 4 open PRs (apache#3516 apache#3517 apache#3523 apache#3525) + QW 0.9 upgrade task. - DONE.md: added sessions 5-10 block (QW sink pipeline, collector cutover, InvalidOffset bug + fix). - quickwit_sink/src/lib.rs: cargo fmt reformatting only.
There was a problem hiding this comment.
This PR ships at least 5 independent features/fixes in one PR (OTLP source, Dockerfile, COOP_TASKRUN, quickwit_sink rewrite, InvalidOffset fix). I think it will be better to split these? It's really hard to review and bisect if something goes wrong.
| ) -> Result<Response<ExportLogsServiceResponse>, Status> { | ||
| let messages = convert::export_logs_to_messages(request.into_inner()); | ||
| send_messages(&self.tx, messages, "logs").await; | ||
| Ok(Response::new(ExportLogsServiceResponse { | ||
| partial_success: None, | ||
| })) |
There was a problem hiding this comment.
This always returns success to the OTel caller even when messages are dropped
on backpressure. The OTLP spec has partial_success for exactly this case,
and without using it (or returning RESOURCE_EXHAUSTED), collectors/SDKs won't
retry and data is silently lost.
There was a problem hiding this comment.
This code is no longer in this PR. The otlp_source was split into PR #3516, where both partial_success rejection counts and the try_send backpressure behavior have been addressed.
There was a problem hiding this comment.
This flag is per-consumer, not per-partition. With consumer groups assigned
multiple partitions, an InvalidOffset on partition A would force
PollingStrategy::first() on partition B on the next poll, potentially
re-reading from the start of an unrelated partition. I think this should be
a DashMap<u32, AtomicBool> keyed by partition_id instead?
There was a problem hiding this comment.
Addressed: Arc<AtomicBool> replaced with Arc<DashMap<u32, ()>> keyed by partition_id. InvalidOffset on partition A no longer touches other partitions. For auto-assign consumers (partition_id = None) the sentinel u32::MAX is used. The PR has been rebased and now contains only this single-file fix in core/sdk/src/clients/consumer.rs.
| signal: &str, | ||
| ) { | ||
| for message in messages { | ||
| if let Err(err) = tx.try_send(message) { |
There was a problem hiding this comment.
Using try_send means if poll() is slow draining the channel, gRPC export calls
drop data without the sender knowing. Wouldn't send().await with a timeout and
returning Status::RESOURCE_EXHAUSTED be safer here? OTel clients handle that.
There was a problem hiding this comment.
This code is no longer in this PR. The otlp_source was split into PR #3516, where both try_send behavior and partial_success rejection counts have been addressed.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3525 +/- ##
============================================
- Coverage 74.09% 74.08% -0.02%
Complexity 937 937
============================================
Files 1258 1258
Lines 131485 131474 -11
Branches 107354 107387 +33
============================================
- Hits 97426 97397 -29
+ Misses 30969 30948 -21
- Partials 3090 3129 +39
🚀 New features to boost your workflow:
|
92d53ff to
abd0305
Compare
|
Rebased onto upstream master -- PR now contains only the InvalidOffset fix (2 commits, 1 file: Per your feedback on per-partition tracking: replaced The earlier comments about /ready |
|
Looks like your comments @atharvalade got addressed, confirm if that's the case. LGTM. |
When a consumer group is freshly created its server-side stored offset is 0. If the topic has had retention run, offset 0 is no longer valid and the very first poll with PollingStrategy::Next returns InvalidOffset. The existing fallback (PR apache#3525) detected this on the first failed poll and recovered on the second. This meant every new consumer group always logged one InvalidOffset error on startup before settling. Fix: initialize_consumer_group now returns a bool indicating whether the group was newly created. Callers set fallback_to_first = true in that case so the very first poll uses PollingStrategy::First (earliest available) instead of Next, eliminating the guaranteed startup error. Rejoin paths (subscribe_events) receive the same treatment so a group created during a reconnect also starts clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb1ctTeXahLw5EWWHP69gK
When a consumer group is freshly created its server-side stored offset is 0. If the topic has had retention run, offset 0 is no longer valid and the very first poll with PollingStrategy::Next returns InvalidOffset. The existing fallback (PR apache#3525) detected this on the first failed poll and recovered on the second. This meant every new consumer group always logged one InvalidOffset error on startup before settling. Fix: initialize_consumer_group now returns a bool indicating whether the group was newly created. Callers set fallback_to_first = true in that case so the very first poll uses PollingStrategy::First (earliest available) instead of Next, eliminating the guaranteed startup error. Rejoin paths (subscribe_events) receive the same treatment so a group created during a reconnect also starts clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb1ctTeXahLw5EWWHP69gK
|
Updated with an additional improvement: instead of detecting
Both the initial |
|
Update: changed recovery strategy from Investigated whether Root cause of the TCP/HTTP discrepancy is TBD. Using Will open a separate investigation for the |
- AGENTS.md: updated READY FOR HANDOVER (5 PRs, segment cleaner note, connectors list). Added segment cleaner + connectors to infra quick-ref. - TODO.md: added apache#3529, reviewer action items for apache#3525 and apache#3516, segment cleaner task, TBD investigations. - DONE.md: added sessions 12-13 block (per-partition DashMap, pre-arm, last(), otlp_sink, proto format). - TOBEDECIDED.md: documented TCP first() bug and otlp_source backpressure.
let me check |
|
@mfyuce Can you check failing CI before I review? |
|
Sorry for the late response -- was doing a local benchmark to make sure the setup is working. The failing test is This is a server-level integration test about segment deletion with consumer group barriers. PR #3525 only touches The assertion failure ( |
|
@atharvalade The integration test failure has been fixed by removing the pre-arm offset logic and using a purely reactive recovery strategy. CI is now green. Ready for review! |
|
/ready |
When a consumer group is freshly created its server-side stored offset is 0. If the topic has had retention run, offset 0 is no longer valid and the very first poll with PollingStrategy::Next returns InvalidOffset. The existing fallback (PR apache#3525) detected this on the first failed poll and recovered on the second. This meant every new consumer group always logged one InvalidOffset error on startup before settling. Fix: initialize_consumer_group now returns a bool indicating whether the group was newly created. Callers set fallback_to_first = true in that case so the very first poll uses PollingStrategy::First (earliest available) instead of Next, eliminating the guaranteed startup error. Rejoin paths (subscribe_events) receive the same treatment so a group created during a reconnect also starts clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb1ctTeXahLw5EWWHP69gK
d747d7d to
9e26e74
Compare
When a consumer group's stored offset falls below the topic's earliest available offset — for example after the server purges old segments under a retention policy — poll_messages returns IggyError::InvalidOffset. The consumer was retrying at the same invalid offset indefinitely, causing sink connectors to loop on errors and stop delivering messages. Add `fallback_to_first: Arc<AtomicBool>` to IggyConsumer. On InvalidOffset, set the flag and emit a warning. On the next poll, PollingStrategy::first() is used to seek to the earliest available message; the flag is cleared after the first successful non-empty poll so normal next-offset tracking resumes.
A single IggyConsumer can be assigned multiple partitions by the consumer group. The previous AtomicBool was global: an InvalidOffset on partition A would force PollingStrategy::first() on all partitions (including B, C, ...) on the next poll, causing unnecessary rewinds. Replace with DashMap<u32, ()> keyed by partition_id so only the affected partition triggers a first()-recovery. u32::MAX is the sentinel for auto-assign consumers (partition_id = None) where the server determines the assigned partition; behavior for those is unchanged from before. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb1ctTeXahLw5EWWHP69gK
When a consumer group is freshly created its server-side stored offset is 0. If the topic has had retention run, offset 0 is no longer valid and the very first poll with PollingStrategy::Next returns InvalidOffset. The existing fallback (PR apache#3525) detected this on the first failed poll and recovered on the second. This meant every new consumer group always logged one InvalidOffset error on startup before settling. Fix: initialize_consumer_group now returns a bool indicating whether the group was newly created. Callers set fallback_to_first = true in that case so the very first poll uses PollingStrategy::First (earliest available) instead of Next, eliminating the guaranteed startup error. Rejoin paths (subscribe_events) receive the same treatment so a group created during a reconnect also starts clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qb1ctTeXahLw5EWWHP69gK
…covery PollingStrategy::first() maps to PollingKind::First on the server, which returned InvalidOffset(0) via the TCP binary protocol even when offset 0 exists (HTTP path works). Root cause unclear -- likely a server-side auto_commit + TCP handler interaction. PollingStrategy::last() always succeeds since the most recently written message is always in an active segment. After the recovery poll commits the latest offset, subsequent polls continue from there normally. TBD: investigate why first() returns InvalidOffset(0) over TCP while HTTP works fine. Once resolved, prefer first() to avoid skipping history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
hubcio
left a comment
There was a problem hiding this comment.
thanks for the detailed report - the stuck-sink problem is real and worth fixing, but this particular fix works against you (details inline). the short version: on current master the scenario this PR targets cannot happen, and the part of the patch that does run changes delivery semantics for every new consumer group. here's how i'd approach it instead:
1. root-cause first. on current master, poll cannot return InvalidOffset at all - the only code path producing it is validate_partition_offset, which is reached exclusively from the store-consumer-offset command. its Invalid offset: 0 signature matches storing an offset on an empty partition (messages_count == 0 && current_offset == 0), not a retention purge. so the production loop came from an older server version (or the store path), and that needs confirming against the exact deployed version before any client-side code is written - otherwise we're fixing a ghost.
2. the server owns this invariant, and master already implements the fix. "stored offset became invalid because the server deleted data" is server-created state, so the server heals it: poll with next() and a missing/stale offset skips forward to the earliest available message (see the PollingKind::Next arm in partitions/ops.rs), and the retention tests in message_cleanup_scenario.rs verify exactly the segments-deleted-then-poll case. if a released version you run still errors on poll, the right move is backporting that clamp - not a client-side workaround that silently seeks behind the application's back.
3. if we want SDK-side recovery at all, it should be an explicit opt-in policy, kafka-style. a builder option like auto_offset_reset = error | earliest | latest, defaulting to error (today's behavior). on InvalidOffset from poll: one recovery poll per the chosen policy, state keyed by the partition id from the actual poll response (not a shared sentinel), a plain AtomicBool/enum instead of a DashMap, disarmed on any non-error poll, and never pre-armed for new groups. it would also need to be specified cross-sdk - right now java/go/c#/node/c++ all propagate poll errors with no fallback (python wraps the rust sdk, so it would silently inherit whatever we do here), and a rust-only silent seek breaks behavioral parity. worth being honest that against current servers this code is dead on arrival, so it only pays off if we commit to supporting old servers from new sdks.
4. the operational pain belongs in the connectors runtime. the sink loop discards the error value (let Ok(message) = message else -> generic "failed to receive message") and hot-loops on persistent errors. propagating the concrete error and adding backoff on repeated identical failures fixes the actual symptom you hit - stuck sink, log spam, no diagnosis - for every error class, not just this one.
5. optionally, kill the error class at the source: the server could accept storing offset 0 on an empty partition as a no-op instead of rejecting it with InvalidOffset. small change, separate PR.
also: no tests cover the new path here. whatever shape this ends up taking needs at minimum a regression test that a brand-new consumer group on a topic with existing messages consumes from the earliest available offset, not the tail.
before implementing fix, please discuss that with us.
| // for that partition uses PollingStrategy::last(). Removed after the first | ||
| // successful recovery poll. Keyed by partition_id; u32::MAX is the sentinel | ||
| // for consumers with no fixed partition (consumer-group auto-assign). | ||
| fallback_to_last: Arc<DashMap<u32, ()>>, |
There was a problem hiding this comment.
effective_pid is constant for the consumer's lifetime (partition_id is set once in the constructor and never reassigned), so this map holds at most one key - it's functionally a bool. the PR description says Arc<AtomicBool>, which is the right call: simpler, lock-free, and it removes the u32::MAX sentinel entirely (which today silently no-ops for a consumer group built with an explicit partition id, since the pre-arm inserts u32::MAX but the poll checks the real partition id). the 'per-partition' comment above doesn't match what the code can do.
| // had retention run, offset 0 may no longer exist. Pre-arm the fallback | ||
| // so the very first poll uses PollingStrategy::last() instead of Next, | ||
| // avoiding a guaranteed InvalidOffset error on startup. | ||
| if newly_created { |
There was a problem hiding this comment.
the premise here doesn't hold on current master: Next with no stored offset starts from the first available segment (see the PollingKind::Next arm in partitions/ops.rs), it never returns InvalidOffset. so there's no startup error to avoid - but the pre-arm itself now forces every freshly created consumer group's first poll to last(), which silently skips the whole existing backlog and (with auto-commit) commits past it, permanently. that's a behavior change for every new consumer group, retention or not. the connector sinks use exactly this config (consumer group, no partition id, next(), auto-commit on poll, create-if-not-exists), so a fresh sink deploy against a topic with existing messages drops everything except the last batch. same pre-arm fires on the rejoin path below, and since the armed flag survives empty polls, a group created against an empty topic also skips history if more than a batch accumulates before its first non-empty poll. dropping the pre-arm entirely seems right - the server already handles the fresh-group case.
| } | ||
|
|
||
| let effective_pid = partition_id.unwrap_or(u32::MAX); | ||
| let polling_strategy = if fallback_to_last.contains_key(&effective_pid) { |
There was a problem hiding this comment.
for a consumer group without a fixed partition, effective_pid is always u32::MAX, but the server picks a (potentially different) partition per poll via round-robin. the armed flag doesn't follow the partition that had the problem - the last() poll lands on whichever partition the server picks next, so on a multi-partition topic exactly one (nondeterministic) partition gets tail-skipped while the others start from earliest. inconsistent starting state within one group, and on servers that do return InvalidOffset from poll this can repeatedly skip healthy partitions.
|
|
||
| let effective_pid = partition_id.unwrap_or(u32::MAX); | ||
| let polling_strategy = if fallback_to_last.contains_key(&effective_pid) { | ||
| PollingStrategy::last() |
There was a problem hiding this comment.
the PR title and description say fall back to the first available offset (PollingStrategy::first()), but the code uses last() - these are opposites. first() replays from the earliest retained message (at-least-once, nothing avoidable lost); last() jumps to the tail and drops the entire still-retained backlog. for the sink use case that's silent data loss. if the fallback stays it should be first(), and disarming on any non-error poll (rather than non-empty) avoids a re-arm loop when the tail was already consumed.
| // earliest available offset (e.g. after retention removes old | ||
| // segments), seek to the most recent message on the next poll | ||
| // instead of looping forever at the invalid offset. | ||
| if matches!(error, IggyError::InvalidOffset(_)) { |
There was a problem hiding this comment.
on current master this branch is unreachable: the only place the server produces InvalidOffset is validate_partition_offset, which is only called from the store-consumer-offset path - the poll path skip-forwards or returns empty for out-of-range offsets instead of erroring, and the retention tests in message_cleanup_scenario.rs poll offset(0) after segments were deleted and .unwrap() successfully. so against a current server this recovery never fires and only the pre-arm behavior change remains. also worth re-checking the root cause: Invalid offset: 0 matches the store-path rejection for an empty partition (messages_count == 0 && current_offset == 0), not a retention purge - the production loop was probably an older server or the store path. worth verifying against the actual released version before shipping a client-side workaround.
| // instead of looping forever at the invalid offset. | ||
| if matches!(error, IggyError::InvalidOffset(_)) { | ||
| warn!( | ||
| "Consumer offset is before the earliest available message in topic: {topic_id}, stream: {stream_id}. \ |
There was a problem hiding this comment.
this states a cause the server can't produce - InvalidOffset fires for an offset beyond the end or an empty partition, never for 'before the earliest available' (the server skip-forwards that case). and 'falling back to latest' contradicts the PR title's 'first available offset'. worth making the text neutral about the cause.
| } | ||
| } | ||
| } | ||
| true |
There was a problem hiding this comment.
if two consumers race to create the same group, the loser hits ConsumerGroupNameAlreadyExists above and still ends up with newly_created = true here - it didn't create anything. with the pre-arm this means both consumers arm the fallback. the AlreadyExists arm should yield false.
ce3a460 to
329a39b
Compare
Problem
When a consumer group's stored offset falls below the topic's earliest
available offset — for example after the server purges old segments
under a retention policy —
poll_messagesreturnsIggyError::InvalidOffset. The consumer retried at the same invalidoffset indefinitely, causing sink connectors to loop on errors and stop
delivering messages.
This manifests in production as:
The root cause: new consumer groups receive stored offset 0 from the
server, but if the topic's retention policy has already purged messages
at offset 0 the very first poll returns
InvalidOffset(0), and theconsumer has no recovery path.
Fix
Add
fallback_to_first: Arc<AtomicBool>toIggyConsumer.IggyError::InvalidOffset, set the flag and emit awarn!withstream/topic context.
create_poll_messages_futurecall, usePollingStrategy::first()instead of the configured strategy so theconsumer seeks to the earliest available message.
next-offset tracking resumes.
No public API changes. The existing
polling_strategyfield and allbuilder methods are unchanged.
Test
cargo test -p iggypasses (120 + 3 tests, 0 failed).The fix was validated in a production
yucemonitoringvcluster wherefour iggy-connectors QuickWit sink connectors were stuck in the
InvalidOffset(0)loop against anotel/metricstopic with ~20 Mmessages and retention. After deleting the stale consumer groups and
restarting (the current workaround), and confirming this fix would have
recovered automatically, ingestion resumed at ~2 700 docs/s.
🤖 Generated with Claude Code