[ 
https://issues.apache.org/jira/browse/KAFKA-20985?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Gavin Wang reassigned KAFKA-20985:
----------------------------------

    Assignee: Gavin Wang

> Static consumer can be fenced by its own abandoned JoinGroup and crash with 
> FencedInstanceIdException in classic protocol
> -------------------------------------------------------------------------------------------------------------------------
>
>                 Key: KAFKA-20985
>                 URL: https://issues.apache.org/jira/browse/KAFKA-20985
>             Project: Kafka
>          Issue Type: Bug
>          Components: clients, streams
>            Reporter: Gavin Wang
>            Assignee: Gavin Wang
>            Priority: Major
>
> A static membership consumer on the classic protocol can be fenced by its own 
> abandoned {{JoinGroup}} request and crash with {{FencedInstanceIdException}}, 
> even though no duplicate instance exists. We hit this on a long-running Kafka 
> Streams application (classic protocol, static membership, EOS) when a group 
> coordinator stall delayed request processing by about 15 seconds: both 
> application instances received the exception on {{JoinGroup}}, and Streams 
> treats it as fatal ({{SHUTDOWN_APPLICATION}}). The client behavior involved 
> dates back to the introduction of static membership (KAFKA-8500). KAFKA-9659 
> fixed the same self-fencing shape, but only for the offset commit path.
>   Failure sequence:
>   1. A static membership consumer calls {{unsubscribe()}}, which calls 
> {{AbstractCoordinator.maybeLeaveGroup()}}. It recognizes the consumer is 
> static and skips the {{LeaveGroup}} RPC, but 
> {{resetGenerationOnLeaveGroup()}} is called regardless {{(*)}} and clears the 
> local member id:
>   {code:java}
>   if (shouldSendLeaveGroupRequest(membershipOperation)) {
>       log.info("Member {} sending LeaveGroup request to coordinator {} due to 
> {}",
>           generation.memberId, coordinator, leaveReason);
>       ...
>   }
>   resetGenerationOnLeaveGroup();   // (*) runs even when no LeaveGroup was 
> sent
>   private boolean 
> shouldSendLeaveGroupRequest(CloseOptions.GroupMembershipOperation 
> membershipOperation) {
>       if (!coordinatorUnknown() && state != MemberState.UNJOINED && 
> generation.hasMemberId()) {
>           return membershipOperation == LEAVE_GROUP || (isDynamicMember() && 
> membershipOperation == DEFAULT);
>       } else {
>           return false;
>       }
>   }
>   {code}
>   Every subsequent rejoin therefore carries {{UNKNOWN_MEMBER_ID}}, while the 
> coordinator still has the old member id registered for this 
> {{group.instance.id}}.
>   2. The consumer rejoins immediately via {{JoinGroup}} with 
> {{UNKNOWN_MEMBER_ID}}. Call it Join #1.
>   3. The coordinator is stalled at this point (in our case its request 
> pipeline was about 15 seconds deep while the {{__consumer_offsets}} partition 
> was reloading after a leadership change). On the consumer's coordinator 
> connection, heartbeat HB1 is already sitting in the coordinator's request 
> queue, and a later heartbeat HB2 has been delivered but not yet read, because 
> the broker keeps at most one request per connection in flight and does not 
> read the socket again until the in-flight request is answered. Join #1's 
> bytes land in the connection's receive buffer behind HB2, unread. HB1 
> eventually returns {{COORDINATOR_NOT_AVAILABLE}}, so the client marks the 
> coordinator unknown and closes the connection, cancelling HB2 and Join #1 in 
> its own bookkeeping only. The close cannot take back bytes already delivered: 
> the broker will still read and process them whenever reading resumes. The 
> client then rediscovers the same coordinator, opens a new connection, and 
> sends Join #2 (still {{UNKNOWN_MEMBER_ID}}, since the id was wiped in step 1).
>   4. Join #2 arrives on the fresh connection with nothing in front of it, so 
> it is read and enqueued immediately, roughly 15 seconds ahead of Join #1, 
> which is still unread bytes (the queue orders by read time, not send time). 
> When the stall clears, the backlog drains: HB2 executes (its response goes to 
> the closed socket and is dropped), which resumes reading on the old 
> connection, and Join #1 is finally parsed and enqueued behind Join #2. Join 
> #2 executes first: unknown member id plus known {{group.instance.id}} is 
> handled as a static member replacement ({{classicGroupJoinNewStaticMember}} 
> -> {{updateStaticMemberThenRebalanceOrCompleteJoin}} -> 
> {{ClassicGroup.replaceStaticMember}}), and its response is held while the new 
> membership record replicates. Then Join #1 executes carrying the exact same 
> signature. The coordinator cannot distinguish a retry by the same consumer 
> from a second process claiming the instance id, so it applies the replacement 
> logic again: it evicts Join #2's member, and {{replaceStaticMember}} 
> completes Join #2's pending response with {{FENCED_INSTANCE_ID}}:behind HB2, 
> unread. HB1 eventually returns {{COORDINATOR_NOT_AVAILABLE}}, so the client 
> marks the coordinator unknown and closes the connection, cancelling HB2 and 
> Join #1 in its own bookkeeping only. The close cannot take back bytes already 
> delivered: the broker will still read and process them whenever reading 
> resumes. The client then rediscovers the same coordinator, opens a new 
> connection, and sends Join #2 (still {{UNKNOWN_MEMBER_ID}}, since the id was 
> wiped in step 1).
>   4. Join #2 arrives on the fresh connection with nothing in front of it, so 
> it is read and enqueued immediately, roughly 15 seconds ahead of Join #1, 
> which is still unread bytes (the queue orders by read time, not send time). 
> When the stall clears, the backlog drains: HB2 executes (its response goes to 
> the closed socket and is dropped), which resumes reading on the old 
> connection, and Join #1 is finally parsed and enqueued behind Join #2. Join 
> #2 executes first: unknown member id plus known {{group.instance.id}} is 
> handled as a static member replacement ({{classicGroupJoinNewStaticMember}} 
> -> {{updateStaticMemberThenRebalanceOrCompleteJoin}} -> 
> {{ClassicGroup.replaceStaticMember}}), and its response is held while the new 
> membership record replicates. Then Join #1 executes carrying the exact same 
> signature. The coordinator cannot distinguish a retry by the same consumer 
> from a second process claiming the instance id, so it applies the replacement 
> logic again: it evicts Join #2's member, and {{replaceStaticMember}} 
> completes Join #2's pending response with {{FENCED_INSTANCE_ID}}:
>   {code:java}
>   // Fence potential duplicate member immediately if someone awaits join/sync 
> future.
>   JoinGroupResponseData joinGroupResponse = new JoinGroupResponseData()
>       ...
>       .setErrorCode(Errors.FENCED_INSTANCE_ID.code());
>   completeJoinFuture(removedMember, joinGroupResponse);
>   {code}
>   Join #2's connection is still open on the client side, so this error is 
> actually delivered; the consumer throws {{FencedInstanceIdException}}, which 
> is treated as fatal. Join #1's own success response is dropped on the closed 
> connection.
>   The problem: fencing the earlier pending attempt is correct when two 
> distinct processes really share a {{group.instance.id}}. The bug is that 
> {{(*)}} makes a lone consumer produce that exact signature against itself: 
> clearing the member id without having sent {{LeaveGroup}} makes every rejoin 
> indistinguishable from a duplicate process, so any situation that leaves one 
> {{JoinGroup}} abandoned but deliverable (a coordinator stall plus a 
> client-side disconnect, in our case) lets the abandoned request fence the 
> retry.
>   Proposed fix: when {{maybeLeaveGroup()}} suppresses the {{LeaveGroup}} RPC 
> for a static member, keep the member id instead of resetting it. Both joins 
> then identify as the existing member and are handled as a rejoin rather than 
> a replacement, so no ordering of connections and queues can produce a 
> self-fence.



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

Reply via email to