fix(queue): self-heal DLQ commit-starvation via reseek on no-ack#443
Conversation
A consumer chunk that returns no acks (transient write/DLQ-publish failure → routeBatchOutcome []) does not advance the commit base, but the poll position has already moved past it. librdkafka never redelivers uncommitted offsets in-session, so the base pins forever while the consumer forward-processes to log-end and goes idle — the 2026-06-25 apitoolkit_eu_dlq wedge (~7.6M lag, committed offsets frozen). Fix: on no-ack, the worker records the chunk's lowest offset in reseekVar; the poll thread (which owns all Kafka ops) seeks each such partition back to that offset and resets its tracker so the un-acked chunk is redelivered and retried once the failure clears. Seeks backward only (never past the committed base) so it can never skip — worst case redelivers already-processed offsets, which is idempotent downstream. Regression test in KafkaConsumerSpec drives a chunk that no-acks on first processing then acks: without the fix the base pins and the watermark never recovers (drive times out); with it the reseek redelivers and the commit advances. Adds SeekPartitions to the in-memory broker mock.
Code ReviewThis PR fixes a real production incident: a no-ack chunk (transient write/DLQ failure → 1.
|
| # | File | Line | Kind | One-liner |
|---|---|---|---|---|
| 1 | Queue.hs |
~560 | Bug | reseekVar not pruned on rebalance → stale entry re-inserted, never cleaned |
| 2 | Queue.hs |
545 | Cleanup | minimum on sorted list → head (O(1), intent clearer) |
| 3 | Queue.hs |
594 | Cleanup | foldl' over Map.toList → Map.foldlWithKey' or <> (no intermediate list) |
| 4 | KafkaConsumerSpec.hs |
172 | Test | Assert attemptVar >= 2 to make regression guard non-hollow |
| 5 | KafkaConsumerSpec.hs |
86 | Test | seekCursor ignores partition ID — multi-partition seek not faithfully modelled |
…st rigor - Prune reseekVar to the live partition set in the committer (alongside tracker/committed). Without it, a partition that stalled (reseek recorded) then got revoked would make the poll thread seek a partition this consumer no longer owns and re-seed a stale tracker entry the prune can never reach. - Tracker reset on reseek uses the left-biased Map union (Map.map (`PartProgress` mempty) reseeks <> tk) instead of folding a toList. - Test: assert the retry actually fired (attemptVar >= 2), so removing the no-ack guard can't make the test pass trivially. - Test mock: key the broker cursor by (topic, partition) so same-topic multi-partition seeks no longer silently collide. (head-instead-of-minimum was not applied: head trips -Werror=x-partial; minimum is the lint-clean partial reducer and the offset list is small.)
Code ReviewThe fix correctly identifies the root cause (poll cursor advancing past un-acked offsets that librdkafka won't redeliver) and the reseek-on-no-ack approach is sound. A few issues worth addressing: Bug — Non-atomic seek + tracker reset (Queue.hs:597-598)KE.seekPartitions [...] (K.Timeout 5000) -- IO, up to 5s
atomically $ modifyTVar' trackerVar \tk -> Map.map (`PartProgress` mempty) reseeks <> tkThere's a real race window between these two non-atomic steps. While The result is duplicate processing rather than data loss (idempotent downstream), but under DLQ broker degradation this window is longest and most likely to trigger. Notably, the test mock makes Simplest fix: reset the tracker before the seek (the seek failing is benign — the next poll cycle will retry), or accept the race explicitly in a comment and note it's covered by downstream idempotence. Type safety —
|
Code ReviewThe PR correctly diagnoses and fixes the 2026-06-25 commit-starvation wedge: on no-ack, record the lowest unacked offset and have the poll thread reseek, so librdkafka redelivers the un-committed chunk. The approach is sound and the regression test is well-structured. Three issues need attention before merge. Bug 1 — seekPartitions exception permanently re-wedges the consumer (Critical)
reseeks <- atomically $ swapTVar reseekVar Map.empty -- reseekVar is now empty
...
KE.seekPartitions [...] (K.Timeout 5000) -- can throw
Fix: wrap the seek in reseeks <- atomically $ swapTVar reseekVar Map.empty
unless (Map.null reseeks) do
result <- tryAny $ KE.seekPartitions [...] (K.Timeout 5000)
case result of
Left _ -> atomically $ modifyTVar' reseekVar (reseeks <>) -- restore, retry next poll
Right _ -> do
atomically $ modifyTVar' trackerVar \tk -> Map.map (`PartProgress` mempty) reseeks <> tk
LogBase.logInfo "kafka.consumer.reseek" ...Bug 2 — Tracker reset clears
|
…break) Every "Build and Test" run has failed — across all PRs and the merged ones — compiling test/unit/System/ServerSpec.hs (added in #440) with: GHC-87110: Could not load module 'Control.Concurrent'. It is a member of the hidden package 'base-4.21.0.0'. Perhaps you need to add 'base' to build-depends. ServerSpec imports Control.Concurrent / Control.Exception directly from base; every other test/unit module goes through relude, so the unit-tests suite never listed base. With NoImplicitPrelude the direct base imports can't resolve and the suite fails to compile (the varying error line across runs was just whichever import GHC reached first — not a flake). Add base to the unit-tests build-depends.
7453e82 to
4b0722f
Compare
Code ReviewThe fix is conceptually sound: on no-ack, record the minimum un-acked offset per partition in a shared Bug: reseek entries permanently lost on
|
Problem
A consumer chunk that returns no acks (transient write/DLQ-publish failure →
routeBatchOutcome []) does not advance the commit base, but the poll position has already moved past it. librdkafka never redelivers uncommitted offsets in-session, so the base pins forever while the consumer forward-processes to log-end and goes idle.This is the 2026-06-25
apitoolkit_eu_dlqwedge: group Stable, but committed offsets on the big partitions frozen (~7.6M lag). Data is durable (forward-written to TimeFusion) — only the offset commit is stuck — so restarts give a brief burst then re-freeze at the next no-ack chunk.Fix
decoupledLoopnow self-heals commit-starvation:reseekVar.seekPartitionseach such partition backward to that offset and resets its tracker (PartProgress o mempty) so the un-acked chunk is redelivered and retried once the transient failure clears.Seeks backward only (never past the committed base) → can never skip/lose. Worst case redelivers already-processed offsets, which is idempotent downstream.
Test
Regression test in
KafkaConsumerSpec("commit-starvation self-heal"): a chunk that no-acks on first processing then acks. Without the fix the base pins and the watermark never recovers (drive times out); with it the reseek redelivers and the commit advances. AddsSeekPartitionsto the in-memory broker mock.Follow-up (not in this PR)
Drop
group.instance.id(dynamic membership) to stop restart-orphaning of partitions — separate concern, changes rebalance behavior, deserves its own PR.