Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,11 @@ public CompletableFuture<HeartbeatResponseData> heartbeat(
);
}

// Using a read operation is okay here as we ignore the last committed offset in the snapshot registry.
// This means we will read whatever is in the latest snapshot, which is how the old coordinator behaves.
return runtime.scheduleReadOperation(
return runtime.scheduleWriteOperation(
"classic-group-heartbeat",
topicPartitionFor(request.groupId()),
(coordinator, __) -> coordinator.classicGroupHeartbeat(context, request)
Duration.ofMillis(config.offsetCommitTimeoutMs),

Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Member

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 offsetCommitTimeoutMs as 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.

coordinator -> coordinator.classicGroupHeartbeat(context, request)
).exceptionally(exception -> handleOperationException(
"classic-group-heartbeat",
request,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,9 +355,10 @@ public CoordinatorResult<Void, CoordinatorRecord> classicGroupSync(
* @param context The request context.
* @param request The actual Heartbeat request.
*
* @return The HeartbeatResponse.
* @return A Result containing the heartbeat response and
* a list of records to update the state machine.
*/
public HeartbeatResponseData classicGroupHeartbeat(
public CoordinatorResult<HeartbeatResponseData, CoordinatorRecord> classicGroupHeartbeat(
RequestContext context,
HeartbeatRequestData request
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,7 @@ private CoordinatorResult<Void, CoordinatorRecord> classicGroupJoinToConsumerGro
CompletableFuture<Void> appendFuture = new CompletableFuture<>();
appendFuture.whenComplete((__, t) -> {
if (t == null) {
cancelConsumerGroupJoinTimeout(groupId, response.memberId());
Comment thread
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());
Expand Down Expand Up @@ -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.
*
Expand All @@ -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.")
Comment thread
dongnuo123 marked this conversation as resolved.
Outdated
);
}

/**
Expand Down Expand Up @@ -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.")
Comment thread
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.")
);
}

/**
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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(
Comment thread
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 " +
Expand Down Expand Up @@ -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))) {
Comment thread
dongnuo123 marked this conversation as resolved.
Outdated
error = Errors.REBALANCE_IN_PROGRESS;
scheduleConsumerGroupJoinTimeout(groupId, memberId, member.rebalanceTimeoutMs());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we cancel the join timeout when we first convert to consumer group

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.

when we have a group epoch bump we tell the classic group member we're rebalancing and they should send a join request

Yes correct, and the timeout here is for the member instead of the whole group. For each member, the rebalance will be something like

  • heartbeat -- if there's an ongoing rebalance, schedule the join timeout
  • join -- cancel the join timeout; schedule the sync timeout
  • sync -- cancel the sync timeout; maybe schedule a join timeout if a new rebalance ongoing

Comment thread
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.
Comment thread
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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Loading