This is an automated email from the ASF dual-hosted git repository.
lucasbru pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new fb557a346a8 KAFKA-20717: Add broker-side set/get observability sensors
for streams topology description (KIP-1331) (#22664)
fb557a346a8 is described below
commit fb557a346a866113e212cb4f7c844222d54a9aa1
Author: Alieh Saeedi <[email protected]>
AuthorDate: Mon Jun 29 23:35:01 2026 +0200
KAFKA-20717: Add broker-side set/get observability sensors for streams
topology description (KIP-1331) (#22664)
## Summary
Implements the broker-side observability sensors for the streams-group
topology-description plugin's `setTopology` and `getTopology`
operations, as specified in
[KIP-1331](https://cwiki.apache.org/confluence/display/KAFKA/KIP-1331%3A+Streams+Group+Topology+Description+Plugin)
([KAFKA-20717](https://issues.apache.org/jira/browse/KAFKA-20717)).
The `delete` and `cleanup` sensors (KAFKA-20623/KAFKA-20624) are already
in place; this change mirrors their pattern for the remaining
`set`/`get` sensors. New sensors registered under
`kafka.server:type=group-coordinator-metrics`:
- `streams-group-topology-description-set-success-{rate,count}`
- `streams-group-topology-description-set-error-{rate,count}`
- `streams-group-topology-description-get-success-{rate,count}`
- `streams-group-topology-description-get-error-{rate,count}`
## Implementation notes
- **Set**: recorded at the `invokeSetTopology` dispatch in
`GroupCoordinatorService`. `SUCCESS` increments `set-success`; both
`PERMANENT` and `TRANSIENT` fold into `set-error` (any failure type
counts as an error, per the KIP).
- **Get**: recorded in `StreamsGroupTopologyDescriptionManager`, where
`plugin.getTopology` resolves — the only place the per-call outcome
surfaces. A `null` return counts as a **success** (it maps to
`NOT_STORED`, not an error); exceptional/synchronous-throw/null-future
outcomes count as `get-error`.
## Testing
- `GroupCoordinatorMetricsTest`: added the 8 new metric names to the
registration assertions and a new test verifying `recordSensor` drives
the correct meters.
- `GroupCoordinatorServiceTopologyDescriptionTest`: 6 new tests covering
set success/permanent/transient and get available/null-return/error
outcomes.
- Full `GroupCoordinatorServiceTest`, checkstyle, and spotbugs pass.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Reviewers: Lucas Brutschy <[email protected]>, TengYao Chi
<[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Co-authored-by: Copilot Autofix powered by AI
<[email protected]>
---
.../coordinator/group/GroupCoordinatorService.java | 65 +++---
.../group/metrics/GroupCoordinatorMetrics.java | 60 +++++-
.../StreamsGroupTopologyDescriptionManager.java | 28 ++-
...pCoordinatorServiceTopologyDescriptionTest.java | 233 ++++++++++++++++++++-
.../group/metrics/GroupCoordinatorMetricsTest.java | 33 +++
5 files changed, 390 insertions(+), 29 deletions(-)
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
index 8d10af7dd32..579e9726f93 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/GroupCoordinatorService.java
@@ -427,7 +427,8 @@ public class GroupCoordinatorService implements
GroupCoordinator {
this.streamsGroupTopologyDescriptionManager = new
StreamsGroupTopologyDescriptionManager(
logContext,
streamsGroupTopologyDescriptionPlugin,
- time
+ time,
+ groupCoordinatorMetrics
);
}
@@ -743,29 +744,32 @@ public class GroupCoordinatorService implements
GroupCoordinator {
.thenApply(__ ->
StreamsGroupTopologyDescriptionConverter.fromRequest(request.topologyDescription()))
.thenCompose(description ->
streamsGroupTopologyDescriptionManager.invokeSetTopology(
groupId, pushedEpoch, description))
- .thenCompose(pluginOutcome -> switch (pluginOutcome.kind()) {
- case SUCCESS -> runtime.scheduleWriteOperation(
- "streams-group-set-stored-topology-epoch",
- tp,
- coordinator ->
coordinator.setStoredDescriptionTopologyEpoch(groupId, pushedEpoch)
- ).handle((unused, throwable) ->
streamsGroupTopologyDescriptionManager.completeEpochWrite(
- groupId, pushedEpoch, throwable,
- new StreamsGroupTopologyDescriptionUpdateResponseData()));
- case PERMANENT -> runtime.scheduleWriteOperation(
- "streams-group-set-failed-topology-epoch",
- tp,
- coordinator ->
coordinator.setFailedDescriptionTopologyEpoch(groupId, pushedEpoch)
- ).handle((unused, throwable) ->
streamsGroupTopologyDescriptionManager.completeEpochWrite(
- groupId, pushedEpoch, throwable,
- new StreamsGroupTopologyDescriptionUpdateResponseData()
-
.setErrorCode(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code())
- .setErrorMessage(pluginOutcome.message())));
- case TRANSIENT -> {
- streamsGroupTopologyDescriptionManager.armBackoff(groupId,
pushedEpoch);
- yield CompletableFuture.completedFuture(new
StreamsGroupTopologyDescriptionUpdateResponseData()
-
.setErrorCode(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code())
- .setErrorMessage(pluginOutcome.message()));
- }
+ .thenCompose(pluginOutcome -> {
+ recordPluginSetOutcome(pluginOutcome.kind());
+ return switch (pluginOutcome.kind()) {
+ case SUCCESS -> runtime.scheduleWriteOperation(
+ "streams-group-set-stored-topology-epoch",
+ tp,
+ coordinator ->
coordinator.setStoredDescriptionTopologyEpoch(groupId, pushedEpoch)
+ ).handle((unused, throwable) ->
streamsGroupTopologyDescriptionManager.completeEpochWrite(
+ groupId, pushedEpoch, throwable,
+ new
StreamsGroupTopologyDescriptionUpdateResponseData()));
+ case PERMANENT -> runtime.scheduleWriteOperation(
+ "streams-group-set-failed-topology-epoch",
+ tp,
+ coordinator ->
coordinator.setFailedDescriptionTopologyEpoch(groupId, pushedEpoch)
+ ).handle((unused, throwable) ->
streamsGroupTopologyDescriptionManager.completeEpochWrite(
+ groupId, pushedEpoch, throwable,
+ new StreamsGroupTopologyDescriptionUpdateResponseData()
+
.setErrorCode(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code())
+ .setErrorMessage(pluginOutcome.message())));
+ case TRANSIENT -> {
+
streamsGroupTopologyDescriptionManager.armBackoff(groupId, pushedEpoch);
+ yield CompletableFuture.completedFuture(new
StreamsGroupTopologyDescriptionUpdateResponseData()
+
.setErrorCode(Errors.STREAMS_TOPOLOGY_DESCRIPTION_UPDATE_FAILED.code())
+ .setErrorMessage(pluginOutcome.message()));
+ }
+ };
})
.exceptionally(exception -> handleOperationException(
"streams-group-topology-description-update",
@@ -906,6 +910,19 @@ public class GroupCoordinatorService implements
GroupCoordinator {
}
}
+ /**
+ * Record the outcome of a single {@code plugin.setTopology} call against
the
+ * {@code set-success} / {@code set-error} sensors. A {@code SUCCESS}
outcome increments
+ * the success sensor; every failure outcome ({@code PERMANENT} or {@code
TRANSIENT},
+ * regardless of the underlying exception type) increments the error
sensor.
+ */
+ private void
recordPluginSetOutcome(StreamsGroupTopologyDescriptionManager.PluginOutcome.Kind
kind) {
+ String sensorName = kind ==
StreamsGroupTopologyDescriptionManager.PluginOutcome.Kind.SUCCESS
+ ?
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME
+ :
GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME;
+ groupCoordinatorMetrics.recordSensor(sensorName);
+ }
+
/**
* Conditional metadata write that clears {@code
StoredDescriptionTopologyEpoch} for
* {@code groupId} only when the persisted value still equals {@code
expectedStoredEpoch}.
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
index 185ea52d83a..1fb54b6309b 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetrics.java
@@ -126,6 +126,10 @@ public class GroupCoordinatorMetrics extends
CoordinatorMetrics implements AutoC
public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME =
"StreamsGroupTopologyDescriptionCleanupEligibleGroups";
public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME =
"StreamsGroupTopologyDescriptionDeleteSuccess";
public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME =
"StreamsGroupTopologyDescriptionDeleteError";
+ public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME =
"StreamsGroupTopologyDescriptionSetSuccess";
+ public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME =
"StreamsGroupTopologyDescriptionSetError";
+ public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME =
"StreamsGroupTopologyDescriptionGetSuccess";
+ public static final String
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME =
"StreamsGroupTopologyDescriptionGetError";
private final MetricName offsetCountMetricName;
private final MetricName classicGroupCountMetricName;
@@ -442,6 +446,46 @@ public class GroupCoordinatorMetrics extends
CoordinatorMetrics implements AutoC
METRICS_GROUP,
"The total number of failed plugin.deleteTopology calls
(DeleteGroups and periodic cleanup combined)")));
+ Sensor streamsGroupTopologyDescriptionSetSuccessSensor =
+
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME);
+ streamsGroupTopologyDescriptionSetSuccessSensor.add(new Meter(
+
metrics.metricName("streams-group-topology-description-set-success-rate",
+ METRICS_GROUP,
+ "The rate of successful plugin.setTopology calls"),
+
metrics.metricName("streams-group-topology-description-set-success-count",
+ METRICS_GROUP,
+ "The total number of successful plugin.setTopology calls")));
+
+ Sensor streamsGroupTopologyDescriptionSetErrorSensor =
+
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME);
+ streamsGroupTopologyDescriptionSetErrorSensor.add(new Meter(
+
metrics.metricName("streams-group-topology-description-set-error-rate",
+ METRICS_GROUP,
+ "The rate of failed plugin.setTopology calls (any failure:
permanent, transient, or otherwise)"),
+
metrics.metricName("streams-group-topology-description-set-error-count",
+ METRICS_GROUP,
+ "The total number of failed plugin.setTopology calls (any
failure: permanent, transient, or otherwise)")));
+
+ Sensor streamsGroupTopologyDescriptionGetSuccessSensor =
+
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ streamsGroupTopologyDescriptionGetSuccessSensor.add(new Meter(
+
metrics.metricName("streams-group-topology-description-get-success-rate",
+ METRICS_GROUP,
+ "The rate of successful getTopology calls (a call returning
null counts as success)"),
+
metrics.metricName("streams-group-topology-description-get-success-count",
+ METRICS_GROUP,
+ "The total number of successful plugin.getTopology calls (a
call returning null counts as success)")));
+
+ Sensor streamsGroupTopologyDescriptionGetErrorSensor =
+
metrics.sensor(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ streamsGroupTopologyDescriptionGetErrorSensor.add(new Meter(
+
metrics.metricName("streams-group-topology-description-get-error-rate",
+ METRICS_GROUP,
+ "The rate of failed getTopology operations (plugin errors, SPI
contract violations, or conversion failures)"),
+
metrics.metricName("streams-group-topology-description-get-error-count",
+ METRICS_GROUP,
+ "The total number of failed plugin.getTopology calls")));
+
globalSensors = Collections.unmodifiableMap(Utils.mkMap(
Utils.mkEntry(OFFSET_COMMITS_SENSOR_NAME, offsetCommitsSensor),
Utils.mkEntry(OFFSET_EXPIRED_SENSOR_NAME, offsetExpiredSensor),
@@ -457,7 +501,15 @@ public class GroupCoordinatorMetrics extends
CoordinatorMetrics implements AutoC
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME,
streamsGroupTopologyDescriptionDeleteSuccessSensor),
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME,
- streamsGroupTopologyDescriptionDeleteErrorSensor)
+ streamsGroupTopologyDescriptionDeleteErrorSensor),
+
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME,
+ streamsGroupTopologyDescriptionSetSuccessSensor),
+
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME,
+ streamsGroupTopologyDescriptionSetErrorSensor),
+
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME,
+ streamsGroupTopologyDescriptionGetSuccessSensor),
+
Utils.mkEntry(STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME,
+ streamsGroupTopologyDescriptionGetErrorSensor)
));
}
@@ -566,7 +618,11 @@ public class GroupCoordinatorMetrics extends
CoordinatorMetrics implements AutoC
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_CYCLE_RUNS_SENSOR_NAME,
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_CLEANUP_ELIGIBLE_GROUPS_SENSOR_NAME,
STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_SUCCESS_SENSOR_NAME,
- STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME
+ STREAMS_GROUP_TOPOLOGY_DESCRIPTION_DELETE_ERROR_SENSOR_NAME,
+ STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME,
+ STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME,
+ STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME,
+ STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME
).forEach(metrics::removeSensor);
}
diff --git
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
index 5955155b9fa..8a81bab8616 100644
---
a/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
+++
b/group-coordinator/src/main/java/org/apache/kafka/coordinator/group/streams/StreamsGroupTopologyDescriptionManager.java
@@ -31,6 +31,7 @@ import org.apache.kafka.common.utils.internals.LogContext;
import
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescription;
import
org.apache.kafka.coordinator.group.api.streams.StreamsGroupTopologyDescriptionPlugin;
import
org.apache.kafka.coordinator.group.api.streams.StreamsTopologyDescriptionPermanentFailureException;
+import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
import org.apache.kafka.server.util.timer.Timer;
import org.apache.kafka.server.util.timer.TimerTask;
@@ -73,6 +74,9 @@ public class StreamsGroupTopologyDescriptionManager
implements AutoCloseable {
private final Logger log;
private final Optional<StreamsGroupTopologyDescriptionPlugin> plugin;
private final StreamsGroupTopologyDescriptionBackoff backoff;
+ // Get sensors are recorded here, not in GroupCoordinatorService like
set/delete:
+ // the get outcome is only finally classified inside
applyGetTopologyOutcome, so the metric lives next to that single source of
truth.
+ private final GroupCoordinatorMetrics metrics;
/**
* True between {@link #startCleanupCycle} and {@link #close}. The {@link
TimerTask}
@@ -100,11 +104,13 @@ public class StreamsGroupTopologyDescriptionManager
implements AutoCloseable {
public StreamsGroupTopologyDescriptionManager(
LogContext logContext,
Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
- Time time
+ Time time,
+ GroupCoordinatorMetrics metrics
) {
this.log =
logContext.logger(StreamsGroupTopologyDescriptionManager.class);
this.plugin = plugin;
this.backoff = new StreamsGroupTopologyDescriptionBackoff(time);
+ this.metrics = metrics;
}
/**
@@ -511,10 +517,12 @@ public class StreamsGroupTopologyDescriptionManager
implements AutoCloseable {
pluginFuture = p.getTopology(describedGroup.groupId(),
topologyEpoch);
} catch (Exception e) {
// SPI contract violation: synchronous throw treated as ERROR.
+ recordGetError();
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
return CompletableFuture.completedFuture(null);
}
if (pluginFuture == null) {
+ recordGetError();
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
return CompletableFuture.completedFuture(null);
}
@@ -533,10 +541,15 @@ public class StreamsGroupTopologyDescriptionManager
implements AutoCloseable {
Throwable cause = Errors.maybeUnwrapException(throwable);
log.warn("Topology description plugin getTopology failed for group
{}.",
describedGroup.groupId(), cause != null ? cause : throwable);
+ recordGetError();
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
return;
}
+ // The plugin call itself completed normally. A null return is the
documented
+ // "plugin no longer has the data" path and surfaces as NOT_STORED,
not an error,
+ // so it still counts as a successful getTopology.
if (topology == null) {
+ recordGetSuccess();
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_NOT_STORED);
return;
}
@@ -544,13 +557,24 @@ public class StreamsGroupTopologyDescriptionManager
implements AutoCloseable {
describedGroup.setTopologyDescription(
StreamsGroupTopologyDescriptionConverter.toDescribeResponse(topology));
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_AVAILABLE);
+ recordGetSuccess();
} catch (Exception conversionError) {
- // Defensive catch, should be unreachable in practice
+ // Defensive catch, should be unreachable in practice. The outcome
surfaces as
+ // ERROR to the client, so count it as a failed getTopology to
stay consistent.
+ recordGetError();
describedGroup.setTopologyDescription(null);
describedGroup.setTopologyDescriptionStatus(TOPOLOGY_DESCRIPTION_STATUS_ERROR);
}
}
+ private void recordGetSuccess() {
+
metrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ }
+
+ private void recordGetError() {
+
metrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ }
+
// Visible for testing.
StreamsGroupTopologyDescriptionBackoff backoff() {
return backoff;
diff --git
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
index 204e1e95509..668b7011590 100644
---
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
+++
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/GroupCoordinatorServiceTopologyDescriptionTest.java
@@ -42,10 +42,12 @@ import
org.apache.kafka.coordinator.group.api.streams.StreamsTopologyDescription
import org.apache.kafka.coordinator.group.metrics.GroupCoordinatorMetrics;
import org.apache.kafka.coordinator.group.streams.StreamsGroupDescribeResult;
import org.apache.kafka.coordinator.group.streams.StreamsGroupHeartbeatResult;
+import
org.apache.kafka.coordinator.group.streams.StreamsGroupTopologyDescriptionConverter;
import org.apache.kafka.server.share.persister.NoOpStatePersister;
import org.apache.kafka.server.util.timer.MockTimer;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
import java.time.Duration;
import java.util.Collections;
@@ -70,7 +72,9 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -103,13 +107,23 @@ public class
GroupCoordinatorServiceTopologyDescriptionTest {
Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
boolean startup,
MockTimer timer
+ ) {
+ return buildService(runtime, plugin, startup, timer, new
GroupCoordinatorMetrics());
+ }
+
+ private static GroupCoordinatorService buildService(
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime,
+ Optional<StreamsGroupTopologyDescriptionPlugin> plugin,
+ boolean startup,
+ MockTimer timer,
+ GroupCoordinatorMetrics metrics
) {
MockTime time = timer.time();
GroupCoordinatorService service = new GroupCoordinatorService(
new LogContext(),
GroupCoordinatorConfigTest.createGroupCoordinatorConfig(4096,
600000L, 24),
runtime,
- new GroupCoordinatorMetrics(),
+ metrics,
createConfigManager(),
new NoOpStatePersister(),
timer,
@@ -1413,6 +1427,223 @@ public class
GroupCoordinatorServiceTopologyDescriptionTest {
verify(plugin, never()).getTopology(anyString(), anyInt());
}
+ @Test
+ public void testUpdateSuccessRecordsSetSuccessSensor() throws Exception {
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.setTopology(anyString(), anyInt(), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
when(runtime.scheduleWriteOperation(eq("streams-group-set-stored-topology-epoch"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupTopologyDescriptionUpdate(
+ requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
validUpdateRequest()
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME);
+ }
+
+ @Test
+ public void testUpdatePermanentFailureRecordsSetErrorSensor() throws
Exception {
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.setTopology(anyString(), anyInt(), any()))
+ .thenReturn(CompletableFuture.failedFuture(
+ new StreamsTopologyDescriptionPermanentFailureException("too
large")));
+
when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
when(runtime.scheduleWriteOperation(eq("streams-group-set-failed-topology-epoch"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupTopologyDescriptionUpdate(
+ requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
validUpdateRequest()
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME);
+ }
+
+ @Test
+ public void testUpdateTransientFailureRecordsSetErrorSensor() throws
Exception {
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.setTopology(anyString(), anyInt(), any()))
+ .thenReturn(CompletableFuture.failedFuture(new
RuntimeException("backend offline")));
+
when(runtime.scheduleReadOperation(eq("streams-group-topology-description-validate"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupTopologyDescriptionUpdate(
+ requestContext(ApiKeys.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_UPDATE),
validUpdateRequest()
+ ).get(5, TimeUnit.SECONDS);
+
+ // A transient failure is still a failed plugin.setTopology call: it
folds into set-error,
+ // not a separate sensor.
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME);
+ }
+
+ @Test
+ public void testDescribeAvailableRecordsGetSuccessSensor() throws
Exception {
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ StreamsGroupTopologyDescription pojo = new
StreamsGroupTopologyDescription(
+ List.of(new StreamsGroupTopologyDescription.Subtopology("sub-0",
List.of(
+ new StreamsGroupTopologyDescription.Source("src",
Set.of("input"), Set.of())))),
+ List.of()
+ );
+ when(plugin.getTopology("foo",
5)).thenReturn(CompletableFuture.completedFuture(pojo));
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new
StreamsGroupDescribeResult(List.of(describedGroupWithTopology("foo", 5)),
Map.of("foo", 5))));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"),
true
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ }
+
+ @Test
+ public void testDescribeNullReturnRecordsGetSuccessSensor() throws
Exception {
+ // A getTopology returning null is a successful plugin call that found
no data
+ // (surfaces as NOT_STORED) and must count as get-success, not
get-error.
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.getTopology("foo",
5)).thenReturn(CompletableFuture.completedFuture(null));
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new
StreamsGroupDescribeResult(List.of(describedGroupWithTopology("foo", 5)),
Map.of("foo", 5))));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"),
true
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ }
+
+ @Test
+ public void testDescribeErrorRecordsGetErrorSensor() throws Exception {
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.getTopology("foo", 5))
+ .thenReturn(CompletableFuture.failedFuture(new
RuntimeException("plugin offline")));
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new
StreamsGroupDescribeResult(List.of(describedGroupWithTopology("foo", 5)),
Map.of("foo", 5))));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"),
true
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ }
+
+ @Test
+ public void testDescribeSynchronousThrowRecordsGetErrorSensor() throws
Exception {
+ // A getTopology that throws synchronously violates the SPI contract
and surfaces as
+ // ERROR; it must count as get-error.
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.getTopology("foo", 5)).thenThrow(new
RuntimeException("plugin blew up"));
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new
StreamsGroupDescribeResult(List.of(describedGroupWithTopology("foo", 5)),
Map.of("foo", 5))));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"),
true
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ }
+
+ @Test
+ public void testDescribeNullFutureRecordsGetErrorSensor() throws Exception
{
+ // A getTopology that returns a null future violates the SPI contract
and surfaces as
+ // ERROR; it must count as get-error.
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ when(plugin.getTopology("foo", 5)).thenReturn(null);
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new
StreamsGroupDescribeResult(List.of(describedGroupWithTopology("foo", 5)),
Map.of("foo", 5))));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE), List.of("foo"),
true
+ ).get(5, TimeUnit.SECONDS);
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ }
+
+ @Test
+ public void testDescribeConversionErrorRecordsGetErrorSensor() throws
Exception {
+ // The plugin returns a valid topology, but converting it to the wire
response throws.
+ // That defensive catch in applyGetTopologyOutcome is otherwise
unreachable (the Node type
+ // is sealed to Source/Processor/Sink, so a well-formed topology
always converts), so we
+ // force toDescribeResponse to throw to exercise it. The outcome
surfaces as ERROR to the
+ // client and must count as get-error, not get-success.
+ CoordinatorRuntime<GroupCoordinatorShard, CoordinatorRecord> runtime =
mockRuntime();
+ StreamsGroupTopologyDescriptionPlugin plugin =
mock(StreamsGroupTopologyDescriptionPlugin.class);
+ StreamsGroupTopologyDescription pojo = new
StreamsGroupTopologyDescription(
+ List.of(new StreamsGroupTopologyDescription.Subtopology("sub-0",
List.of(
+ new StreamsGroupTopologyDescription.Source("src",
Set.of("input"), Set.of())))),
+ List.of()
+ );
+ when(plugin.getTopology("foo",
5)).thenReturn(CompletableFuture.completedFuture(pojo));
+ when(runtime.scheduleReadOperation(eq("streams-group-describe"),
eq(GROUP_TP), any()))
+ .thenReturn(CompletableFuture.completedFuture(
+ new
StreamsGroupDescribeResult(List.of(describedGroupWithTopology("foo", 5)),
Map.of("foo", 5))));
+
+ GroupCoordinatorMetrics metrics = mock(GroupCoordinatorMetrics.class);
+ GroupCoordinatorService service = buildService(runtime,
Optional.of(plugin), true, new MockTimer(), metrics);
+
+ // CALLS_REAL_METHODS so only toDescribeResponse is overridden; any
other converter call
+ // (none on this path) keeps its real behavior.
+ try (MockedStatic<StreamsGroupTopologyDescriptionConverter> converter =
+ mockStatic(StreamsGroupTopologyDescriptionConverter.class,
CALLS_REAL_METHODS)) {
+ converter.when(() ->
StreamsGroupTopologyDescriptionConverter.toDescribeResponse(any()))
+ .thenThrow(new RuntimeException("conversion failed"));
+
+ service.streamsGroupDescribe(
+ requestContext(ApiKeys.STREAMS_GROUP_DESCRIBE),
List.of("foo"), true
+ ).get(5, TimeUnit.SECONDS);
+ }
+
+
verify(metrics).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+ verify(metrics,
never()).recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+ }
+
private static StreamsGroupDescribeResponseData.DescribedGroup
describedGroupWithTopology(
String groupId, int topologyEpoch
) {
diff --git
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
index df031f07935..cdb0e832626 100644
---
a/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
+++
b/group-coordinator/src/test/java/org/apache/kafka/coordinator/group/metrics/GroupCoordinatorMetricsTest.java
@@ -158,6 +158,14 @@ public class GroupCoordinatorMetricsTest {
metrics.metricName("streams-group-topology-description-delete-success-count",
GroupCoordinatorMetrics.METRICS_GROUP),
metrics.metricName("streams-group-topology-description-delete-error-rate",
GroupCoordinatorMetrics.METRICS_GROUP),
metrics.metricName("streams-group-topology-description-delete-error-count",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-set-success-rate",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-set-success-count",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-set-error-rate",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-set-error-count",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-get-success-rate",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-get-success-count",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-get-error-rate",
GroupCoordinatorMetrics.METRICS_GROUP),
+
metrics.metricName("streams-group-topology-description-get-error-count",
GroupCoordinatorMetrics.METRICS_GROUP),
metrics.metricName(
"streams-group-count",
GroupCoordinatorMetrics.METRICS_GROUP,
@@ -407,6 +415,31 @@ public class GroupCoordinatorMetricsTest {
), 50);
}
+ @Test
+ public void testStreamsGroupTopologyDescriptionSetAndGetSensors() {
+ MetricsRegistry registry = new MetricsRegistry();
+ Time time = new MockTime();
+ Metrics metrics = new Metrics(time);
+ GroupCoordinatorMetrics coordinatorMetrics = new
GroupCoordinatorMetrics(registry, metrics);
+
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_SUCCESS_SENSOR_NAME);
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME);
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_SET_ERROR_SENSOR_NAME);
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_SUCCESS_SENSOR_NAME);
+
coordinatorMetrics.recordSensor(GroupCoordinatorMetrics.STREAMS_GROUP_TOPOLOGY_DESCRIPTION_GET_ERROR_SENSOR_NAME);
+
+ assertMetricValue(metrics, metrics.metricName(
+ "streams-group-topology-description-set-success-count",
GroupCoordinatorMetrics.METRICS_GROUP), 1);
+ assertMetricValue(metrics, metrics.metricName(
+ "streams-group-topology-description-set-error-count",
GroupCoordinatorMetrics.METRICS_GROUP), 2);
+ assertMetricValue(metrics, metrics.metricName(
+ "streams-group-topology-description-get-success-count",
GroupCoordinatorMetrics.METRICS_GROUP), 3);
+ assertMetricValue(metrics, metrics.metricName(
+ "streams-group-topology-description-get-error-count",
GroupCoordinatorMetrics.METRICS_GROUP), 1);
+ }
+
private void assertMetricValue(Metrics metrics, MetricName metricName,
double val) {
assertEquals(val, metrics.metric(metricName).metricValue());
}