-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KAFKA-16793: Heartbeat API for upgrading ConsumerGroup #15988
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 14 commits
1e114cd
fc5435d
c649041
dc01744
93bb1f6
7ca541c
b4daa2f
616bf6b
711ed7c
569b345
e00e49f
9d9688a
9f74d88
6b93ace
1957b81
2d86ae7
f8299c9
ee17fb2
3bf2783
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1753,6 +1753,7 @@ private CoordinatorResult<Void, CoordinatorRecord> classicGroupJoinToConsumerGro | |
| CompletableFuture<Void> appendFuture = new CompletableFuture<>(); | ||
| appendFuture.whenComplete((__, t) -> { | ||
| if (t == null) { | ||
| cancelConsumerGroupJoinTimeout(groupId, response.memberId()); | ||
|
dongnuo123 marked this conversation as resolved.
|
||
| scheduleConsumerGroupSessionTimeout(groupId, response.memberId(), sessionTimeoutMs); | ||
| // The sync timeout ensures that the member send sync request within the rebalance timeout. | ||
| scheduleConsumerGroupSyncTimeout(groupId, response.memberId(), request.rebalanceTimeoutMs()); | ||
|
|
@@ -2077,6 +2078,39 @@ private void scheduleConsumerGroupSessionTimeout( | |
| scheduleConsumerGroupSessionTimeout(groupId, memberId, consumerGroupSessionTimeoutMs); | ||
| } | ||
|
|
||
| /** | ||
| * Fences a member from a consumer group. Returns an empty CoordinatorResult | ||
| * if the group or the member doesn't exist. | ||
| * | ||
| * @param groupId The group id. | ||
| * @param memberId The member id. | ||
| * @param reason The reason for fencing the member. | ||
| * | ||
| * @return The CoordinatorResult to be applied. | ||
| */ | ||
| private <T> CoordinatorResult<T, CoordinatorRecord> consumerGroupFenceMemberOperation( | ||
| String groupId, | ||
| String memberId, | ||
| String reason | ||
| ) { | ||
| try { | ||
| ConsumerGroup group = consumerGroup(groupId); | ||
| ConsumerGroupMember member = group.getOrMaybeCreateMember(memberId, false); | ||
| log.info("[GroupId {}] Member {} fenced from the group because {}.", | ||
| groupId, memberId, reason); | ||
|
|
||
| return consumerGroupFenceMember(group, member, null); | ||
| } catch (GroupIdNotFoundException ex) { | ||
| log.debug("[GroupId {}] Could not fence {} because the group does not exist.", | ||
| groupId, memberId); | ||
| } catch (UnknownMemberIdException ex) { | ||
| log.debug("[GroupId {}] Could not fence {} because the member does not exist.", | ||
| groupId, memberId); | ||
| } | ||
|
|
||
| return new CoordinatorResult<>(Collections.emptyList()); | ||
| } | ||
|
|
||
| /** | ||
| * Schedules (or reschedules) the session timeout for the member. | ||
| * | ||
|
|
@@ -2089,25 +2123,13 @@ private void scheduleConsumerGroupSessionTimeout( | |
| String memberId, | ||
| int sessionTimeoutMs | ||
| ) { | ||
| String key = consumerGroupSessionTimeoutKey(groupId, memberId); | ||
| timer.schedule(key, sessionTimeoutMs, TimeUnit.MILLISECONDS, true, () -> { | ||
| try { | ||
| ConsumerGroup group = consumerGroup(groupId); | ||
| ConsumerGroupMember member = group.getOrMaybeCreateMember(memberId, false); | ||
| log.info("[GroupId {}] Member {} fenced from the group because its session expired.", | ||
| groupId, memberId); | ||
|
|
||
| return consumerGroupFenceMember(group, member, null); | ||
| } catch (GroupIdNotFoundException ex) { | ||
| log.debug("[GroupId {}] Could not fence {} because the group does not exist.", | ||
| groupId, memberId); | ||
| } catch (UnknownMemberIdException ex) { | ||
| log.debug("[GroupId {}] Could not fence {} because the member does not exist.", | ||
| groupId, memberId); | ||
| } | ||
|
|
||
| return new CoordinatorResult<>(Collections.emptyList()); | ||
| }); | ||
| timer.schedule( | ||
| consumerGroupSessionTimeoutKey(groupId, memberId), | ||
| sessionTimeoutMs, | ||
| TimeUnit.MILLISECONDS, | ||
| true, | ||
| () -> consumerGroupFenceMemberOperation(groupId, memberId, "the member session expires.") | ||
|
dongnuo123 marked this conversation as resolved.
Outdated
|
||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -2180,36 +2202,58 @@ private void cancelConsumerGroupRebalanceTimeout( | |
| } | ||
|
|
||
| /** | ||
| * Schedules a sync timeout for the member. | ||
| * Schedules a join timeout for the member. | ||
| * | ||
| * @param groupId The group id. | ||
| * @param memberId The member id. | ||
| * @param rebalanceTimeoutMs The rebalance timeout. | ||
| */ | ||
| private void scheduleConsumerGroupSyncTimeout( | ||
| private void scheduleConsumerGroupJoinTimeout( | ||
| String groupId, | ||
| String memberId, | ||
| int rebalanceTimeoutMs | ||
| ) { | ||
| String key = consumerGroupSyncKey(groupId, memberId); | ||
| timer.schedule(key, rebalanceTimeoutMs, TimeUnit.MILLISECONDS, true, () -> { | ||
| try { | ||
| ConsumerGroup group = consumerGroup(groupId); | ||
| ConsumerGroupMember member = group.getOrMaybeCreateMember(memberId, false); | ||
| log.info("[GroupId {}] Member {} fenced from the group because its session expired.", | ||
| groupId, memberId); | ||
| timer.schedule( | ||
| consumerGroupJoinKey(groupId, memberId), | ||
| rebalanceTimeoutMs, | ||
| TimeUnit.MILLISECONDS, | ||
| true, | ||
| () -> consumerGroupFenceMemberOperation(groupId, memberId, "the member failed to join within timeout.") | ||
|
dongnuo123 marked this conversation as resolved.
Outdated
|
||
| ); | ||
| } | ||
|
|
||
| return consumerGroupFenceMember(group, member, null); | ||
| } catch (GroupIdNotFoundException ex) { | ||
| log.debug("[GroupId {}] Could not fence {} because the group does not exist.", | ||
| groupId, memberId); | ||
| } catch (UnknownMemberIdException ex) { | ||
| log.debug("[GroupId {}] Could not fence {} because the member does not exist.", | ||
| groupId, memberId); | ||
| } | ||
| /** | ||
| * Cancels the join timeout of the member. | ||
| * | ||
| * @param groupId The group id. | ||
| * @param memberId The member id. | ||
| */ | ||
| private void cancelConsumerGroupJoinTimeout( | ||
| String groupId, | ||
| String memberId | ||
| ) { | ||
| timer.cancel(consumerGroupJoinKey(groupId, memberId)); | ||
| } | ||
|
|
||
| return new CoordinatorResult<>(Collections.emptyList()); | ||
| }); | ||
| /** | ||
| * Schedules a sync timeout for the member. | ||
| * | ||
| * @param groupId The group id. | ||
| * @param memberId The member id. | ||
| * @param rebalanceTimeoutMs The rebalance timeout. | ||
| */ | ||
| private void scheduleConsumerGroupSyncTimeout( | ||
| String groupId, | ||
| String memberId, | ||
| int rebalanceTimeoutMs | ||
| ) { | ||
| timer.schedule( | ||
| consumerGroupSyncKey(groupId, memberId), | ||
| rebalanceTimeoutMs, | ||
| TimeUnit.MILLISECONDS, | ||
| true, | ||
| () -> consumerGroupFenceMemberOperation(groupId, memberId, "the member failed to sync within timeout.") | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -4072,19 +4116,7 @@ private CoordinatorResult<Void, CoordinatorRecord> classicGroupSyncToConsumerGro | |
| String groupId = request.groupId(); | ||
| String memberId = request.memberId(); | ||
| String instanceId = request.groupInstanceId(); | ||
|
|
||
| ConsumerGroupMember member; | ||
| if (instanceId == null) { | ||
| member = group.getOrMaybeCreateMember(request.memberId(), false); | ||
| } else { | ||
| member = group.staticMember(instanceId); | ||
| if (member == null) { | ||
| throw new UnknownMemberIdException( | ||
| String.format("Member with instance id %s is not a member of group %s.", instanceId, groupId) | ||
| ); | ||
| } | ||
| throwIfInstanceIdIsFenced(member, groupId, memberId, instanceId); | ||
| } | ||
| ConsumerGroupMember member = validateConsumerGroupMember(group, memberId, instanceId); | ||
|
|
||
| throwIfMemberDoesNotUseClassicProtocol(member); | ||
| throwIfGenerationIdUnmatched(member.memberId(), member.memberEpoch(), request.generationId()); | ||
|
|
@@ -4209,31 +4241,67 @@ private void removePendingSyncMember( | |
| * @param context The request context. | ||
| * @param request The actual Heartbeat request. | ||
| * | ||
| * @return The Heartbeat response. | ||
| * @return The coordinator result that contains the heartbeat response. | ||
| */ | ||
| public HeartbeatResponseData classicGroupHeartbeat( | ||
| public CoordinatorResult<HeartbeatResponseData, CoordinatorRecord> classicGroupHeartbeat( | ||
|
dajac marked this conversation as resolved.
|
||
| RequestContext context, | ||
| HeartbeatRequestData request | ||
| ) { | ||
| ClassicGroup group = getOrMaybeCreateClassicGroup(request.groupId(), false); | ||
| Group group = groups.get(request.groupId(), Long.MAX_VALUE); | ||
|
|
||
| if (group == null) { | ||
| throw new UnknownMemberIdException( | ||
| String.format("Group %s not found.", request.groupId()) | ||
| ); | ||
| } | ||
|
|
||
| if (group.type() == CLASSIC) { | ||
| return classicGroupHeartbeatToClassicGroup((ClassicGroup) group, context, request); | ||
| } else { | ||
| return classicGroupHeartbeatToConsumerGroup((ConsumerGroup) group, context, request); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Handle a classic group HeartbeatRequest to a classic group. | ||
| * | ||
| * @param group The ClassicGroup. | ||
| * @param context The request context. | ||
| * @param request The actual Heartbeat request. | ||
| * | ||
| * @return The coordinator result that contains the heartbeat response. | ||
| */ | ||
| private CoordinatorResult<HeartbeatResponseData, CoordinatorRecord> classicGroupHeartbeatToClassicGroup( | ||
| ClassicGroup group, | ||
| RequestContext context, | ||
| HeartbeatRequestData request | ||
| ) { | ||
| validateClassicGroupHeartbeat(group, request.memberId(), request.groupInstanceId(), request.generationId()); | ||
|
|
||
| switch (group.currentState()) { | ||
| case EMPTY: | ||
| return new HeartbeatResponseData().setErrorCode(Errors.UNKNOWN_MEMBER_ID.code()); | ||
| return new CoordinatorResult<>( | ||
| Collections.emptyList(), | ||
| new HeartbeatResponseData().setErrorCode(Errors.UNKNOWN_MEMBER_ID.code()) | ||
| ); | ||
|
|
||
| case PREPARING_REBALANCE: | ||
| rescheduleClassicGroupMemberHeartbeat(group, group.member(request.memberId())); | ||
| return new HeartbeatResponseData().setErrorCode(Errors.REBALANCE_IN_PROGRESS.code()); | ||
| return new CoordinatorResult<>( | ||
| Collections.emptyList(), | ||
| new HeartbeatResponseData().setErrorCode(Errors.REBALANCE_IN_PROGRESS.code()) | ||
| ); | ||
|
|
||
| case COMPLETING_REBALANCE: | ||
| case STABLE: | ||
| // Consumers may start sending heartbeats after join-group response, while the group | ||
| // is in CompletingRebalance state. In this case, we should treat them as | ||
| // normal heartbeat requests and reset the timer | ||
| rescheduleClassicGroupMemberHeartbeat(group, group.member(request.memberId())); | ||
| return new HeartbeatResponseData(); | ||
| return new CoordinatorResult<>( | ||
| Collections.emptyList(), | ||
| new HeartbeatResponseData() | ||
| ); | ||
|
|
||
| default: | ||
| throw new IllegalStateException("Reached unexpected state " + | ||
|
|
@@ -4274,6 +4342,77 @@ private void validateClassicGroupHeartbeat( | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Handle a classic group HeartbeatRequest to a consumer group. A response with | ||
| * REBALANCE_IN_PROGRESS is returned if 1) the member epoch is smaller than the | ||
| * group epoch, 2) the member is in UNREVOKED_PARTITIONS, or 3) the member is in | ||
| * UNRELEASED_PARTITIONS and all its partitions pending assignment are free. | ||
| * | ||
| * @param group The ConsumerGroup. | ||
| * @param context The request context. | ||
| * @param request The actual Heartbeat request. | ||
| * | ||
| * @return The coordinator result that contains the heartbeat response. | ||
| */ | ||
| private CoordinatorResult<HeartbeatResponseData, CoordinatorRecord> classicGroupHeartbeatToConsumerGroup( | ||
| ConsumerGroup group, | ||
| RequestContext context, | ||
| HeartbeatRequestData request | ||
| ) throws UnknownMemberIdException, FencedInstanceIdException, IllegalGenerationException { | ||
| String groupId = request.groupId(); | ||
| String memberId = request.memberId(); | ||
| String instanceId = request.groupInstanceId(); | ||
| ConsumerGroupMember member = validateConsumerGroupMember(group, memberId, instanceId); | ||
|
|
||
| throwIfMemberDoesNotUseClassicProtocol(member); | ||
| throwIfGenerationIdUnmatched(memberId, member.memberEpoch(), request.generationId()); | ||
|
|
||
| scheduleConsumerGroupSessionTimeout(groupId, memberId, member.classicProtocolSessionTimeout().get()); | ||
|
|
||
| Errors error = Errors.NONE; | ||
| if (member.memberEpoch() < group.groupEpoch() || | ||
| member.state() == MemberState.UNREVOKED_PARTITIONS || | ||
| (member.state() == MemberState.UNRELEASED_PARTITIONS && !group.hasUnreleasedPartitions(member))) { | ||
|
dongnuo123 marked this conversation as resolved.
Outdated
|
||
| error = Errors.REBALANCE_IN_PROGRESS; | ||
| scheduleConsumerGroupJoinTimeout(groupId, memberId, member.rebalanceTimeoutMs()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we are saying that we cancel the join timeout when we first convert to consumer group, then when we have a group epoch bump we tell the classic group member we're rebalancing and they should send a join request. is my understanding correct?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
We don't cancel the timeout in case the conversion fails and the state needs to be reverted. The classic group join timeout does nothing if the group is a consumer group.
Yes correct, and the timeout here is for the member instead of the whole group. For each member, the rebalance will be something like
dongnuo123 marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| return new CoordinatorResult<>( | ||
| Collections.emptyList(), | ||
| new HeartbeatResponseData().setErrorCode(error.code()) | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Validates that (1) the instance id exists and is mapped to the member id | ||
| * if the group instance id is provided; and (2) the member id exists in the group. | ||
| * | ||
| * @param group The consumer group. | ||
| * @param memberId The member id. | ||
| * @param instanceId The instance id. | ||
| * | ||
| * @return The ConsumerGruopMember. | ||
|
dongnuo123 marked this conversation as resolved.
Outdated
|
||
| */ | ||
| private ConsumerGroupMember validateConsumerGroupMember( | ||
| ConsumerGroup group, | ||
| String memberId, | ||
| String instanceId | ||
| ) throws UnknownMemberIdException, FencedInstanceIdException { | ||
| ConsumerGroupMember member; | ||
| if (instanceId == null) { | ||
| member = group.getOrMaybeCreateMember(memberId, false); | ||
| } else { | ||
| member = group.staticMember(instanceId); | ||
| if (member == null) { | ||
| throw new UnknownMemberIdException( | ||
| String.format("Member with instance id %s is not a member of group %s.", instanceId, group.groupId()) | ||
| ); | ||
| } | ||
| throwIfInstanceIdIsFenced(member, group.groupId(), memberId, instanceId); | ||
| } | ||
| return member; | ||
| } | ||
|
|
||
| /** | ||
| * Handle a classic LeaveGroupRequest. | ||
| * | ||
|
|
@@ -4583,6 +4722,20 @@ static String classicGroupSyncKey(String groupId) { | |
| return "sync-" + groupId; | ||
| } | ||
|
|
||
| /** | ||
| * Generate a consumer group join key for the timer. | ||
| * | ||
| * Package private for testing. | ||
| * | ||
| * @param groupId The group id. | ||
| * @param memberId The member id. | ||
| * | ||
| * @return the sync key. | ||
| */ | ||
| static String consumerGroupJoinKey(String groupId, String memberId) { | ||
| return "join-" + groupId + "-" + memberId; | ||
| } | ||
|
|
||
| /** | ||
| * Generate a consumer group sync key for the timer. | ||
| * | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
not necessarily a comment for this PR but i wonder if we should change the name of this config since it's being used for all writes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that we should actually not use
offsetCommitTimeoutMsas timeout for any operations except the one writing offsets. I think that we used it because we had no others. We can address this separately.