Matthias J. Sax created KAFKA-20860:
---------------------------------------

             Summary: Broken error handling in the async consumer background 
thread
                 Key: KAFKA-20860
                 URL: https://issues.apache.org/jira/browse/KAFKA-20860
             Project: Kafka
          Issue Type: Bug
          Components: clients, consumer
    Affects Versions: 3.7.0
            Reporter: Matthias J. Sax


Report generate by Claude:

The async consumer's background thread has three error-containment gaps in 
machinery shared by consumer, share and streams groups. They compound: (2) 
hides (1), and (1)'s failure mode is a silent, permanent, group-wide rebalance 
stall. 
h3. 1. reconciliationInProgress is a latch that a synchronous throw never 
releases

markReconciliationInProgress() runs before synchronous work; 
markReconciliationCompleted() is only ever called from the asynchronous 
completion callbacks. Any exception escaping the synchronous portion leaves the 
flag true forever, and every later maybeReconcile() early-returns at the gate.

In AbstractMembershipManager:
* \{{:885}} the gate -- \{{if (reconciliationInProgress) { ... return; }}}
* \{{:931}} markReconciliationInProgress()
* \{{:949}} markPendingRevocationToPauseFetching(...) -> 
subscriptions.markPendingRevocation ->
  assignedState(tp), which throws IllegalStateException when the partition is 
absent
  (SubscriptionState.java:426-431)
* \{{:953}} signalReconciliationStarted() -- synchronous call returning a future
* \{{:956}} commitResult.whenComplete(...) -- the only route to the three
  markReconciliationCompleted() sites (\{{:1022}}, \{{:1031}}, \{{:1050}}), all 
asynchronous

StreamsMembershipManager has the same shape with a wider synchronous window:

markReconciliationInProgress() at \{{:1117}}, then an invariant 
IllegalStateException at \{{:1154}} and an unchecked subtopologies().get(...) 
at \{{:1292}}/\{{:1307}} (NPE), all before the first future is built at 
\{{:1164}}.

There is a second window in both: revokeAndAssign(...) (consumer \{{:968}}) and
revokeActiveTasks(...) are invoked inside a whenComplete lambda but before the 
inner future chain exists, so a throw is captured by that lambda's dependent 
stage instead of reaching the error branch that clears the flag. This window is 
wide in wall-clock terms -- it opens after the auto-commit-before-rebalance 
round trip, i.e. many runOnce iterations later.

Failure mode -- the member neither dies nor gets fenced:
* state stays RECONCILING and reconciliation never runs again;
* heartbeats continue at the current epoch (separate RequestManager), so no 
session timeout, no   FENCED_MEMBER_EPOCH, no rejoin, ever;
* the member keeps serving its old assignment and never re-echoes its owned 
partitions/tasks;
* the broker therefore never receives the ack it is waiting for, 
partitions/tasks pending   revocation are never released, and OTHER members 
stall in UNRELEASED_PARTITIONS/UNRELEASED_TASKS.

So one client's swallowed exception freezes the entire group's rebalance 
indefinitely.

Long-standing: the same latch shape exists in MembershipManagerImpl at 3.7.0
(markReconciliationInProgress \{{:808}}, sole markReconciliationCompleted 
\{{:911}}) and 3.8.0 (\{{:943}} vs \{{:1054}}/\{{:1064}}/\{{:1083}}), and in 
AbstractMembershipManager at 4.1.0 (\{{:854}} vs \{{:964}}/\{{:973}}/\{{:992}}).

Reachability, stated honestly: I found no currently reachable synchronous throw 
for consumer or share groups. Within a single runOnce() the background thread 
is the only mutator of SubscriptionState (the app thread only reads 
pausedPartitions(), AsyncKafkaConsumer.java:1363), and an unsubscribe() during 
the commit window is caught by maybeAbortReconciliation() because it changes 
state. It IS reachable for streams groups (KIP-1071) -- see the linked issue. 
So this is a live bug for one group type and a defensive fix for the others, in 
shared code, where the cost
asymmetry is stark: a few lines of try/catch against a silent permanent 
group-wide stall.

Fix: wrap the synchronous body from markReconciliationInProgress() to the point 
the future chain is established in try/catch; on throw, log at ERROR and 
markReconciliationCompleted() so the member retries on the next poll rather 
than freezing. Same for the StreamsMembershipManager override and for the 
revokeAndAssign / revokeActiveTasks prologues.
h3. 2. Every response-handling callback silently swallows exceptions

{code:java}
// NetworkClientDelegate.java:385-388
UnsentRequest whenComplete(BiConsumer<ClientResponse, Throwable> callback) {
    handler.future().whenComplete(callback);   // dependent stage discarded
    return this;
}
{code}

FutureCompletionHandler.onComplete completes the source future with 
future.complete(response) (\{{:451}}). Per the CompletableFuture contract, an 
exception thrown by a whenComplete action is captured into the DEPENDENT stage 
and does not propagate to complete(); the source future and the
completing thread are unaffected. That dependent stage is discarded here, and 
nothing anywhere observes it -- this is the only call site in the module and 
there is no isCompletedExceptionally() check. The exception is lost with no 
log, no metric, no counter.

Blast radius (count of .whenComplete( handlers riding this seam per file):
OffsetsRequestManager 12, AbstractMembershipManager 9, StreamsMembershipManager 
8, CommitRequestManager 8, StreamsGroupHeartbeatRequestManager 2, 
ShareConsumeRequestManager 2, FetchRequestManager 2, 
AbstractHeartbeatRequestManager 2, CoordinatorRequestManager 1, 
TopicMetadataRequestManager 1, StreamsGroupTopologyDescriptionRequestManager 1.

Present in 3.7.0 through trunk (verbatim at :385-388 in 4.2 and 4.3); the 
current expression was introduced by c81a725219 (KAFKA-15534, 2023-10-20).

This is why item 1 is undiagnosable, and it silently breaks forward 
compatibility on its own. A concrete example already in the tree: 
StreamsGroupHeartbeatResponse.Status.fromCode (\{{:114-120}}) throws 
IllegalArgumentException on an unknown status code, and 
StreamsMembershipManager.isGroupReady (\{{:802}}) decodes every status on every 
successful heartbeat. The member epoch has already been updated (\{{:763}}) 
when it throws, so the client keeps heartbeating healthily while silently
ignoring every new assignment -- with zero log output attributable to the 
cause. Any future broker release that adds a heartbeat status code does this to 
every existing client.

Fix: make the seam non-silent. In UnsentRequest.whenComplete, observe the 
dependent stage and log callback failures (with API key / request header for 
attribution). One place, covers all eleven managers; highest value-per-line in 
this ticket.
h3. 3. A throwing RequestManager.poll() skips the network pump for that 
iteration

ConsumerNetworkThread.runOnce() (\{{:210-240}}) runs the manager loop 
(\{{:222-226}}) before networkClientDelegate.poll(...) (\{{:228}}), the 
maximumTimeToWait loop (\{{:232-235}}) and reapExpiredApplicationEvents 
(\{{:239}}). An exception from one manager's poll() aborts all remaining 
managers, the network pump and event reaping for that iteration; the catch-all 
in run() (\{{:163-166}}) logs "Unexpected error caught in consumer network 
thread" and loops. A repeating
per-iteration throw in any single manager therefore stops all client I/O -- no 
heartbeats sent, no responses processed, no fetches -- while the thread spins, 
even though the other managers are healthy.

With item 1 this is a one-shot, because the latch suppresses the repeat (which 
is why the streams symptom is a zombie rather than a hot spin). A non-latching 
repeating throw gives the spin.

Fix: isolate per-manager failures in the runOnce loops (log and continue), so 
one manager's defect degrades that manager rather than the whole client.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to