diff --git a/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java b/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java index ff29fbe8e3..f9a97f4089 100644 --- a/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java +++ b/agents/drivers/kafka/src/main/java/com/dbx/agent/kafka/KafkaAgent.java @@ -973,16 +973,7 @@ public final class KafkaAgent { String groupId = stringOrEmpty(params, "groupId"); String topic = stringOrEmpty(params, "topic"); - Map offsets = new HashMap<>(); - JsonArray offsetArray = params.has("offsets") && params.get("offsets").isJsonArray() - ? params.getAsJsonArray("offsets") : new JsonArray(); - - for (JsonElement el : offsetArray) { - JsonObject offsetObj = el.getAsJsonObject(); - int partition = offsetObj.get("partition").getAsInt(); - long offset = offsetObj.get("offset").getAsLong(); - offsets.put(new TopicPartition(topic, partition), new OffsetAndMetadata(offset)); - } + Map offsets = explicitConsumerGroupOffsets(params, topic); // If no explicit offsets, check for a "position" parameter. if (offsets.isEmpty()) { @@ -1026,6 +1017,58 @@ public final class KafkaAgent { return Collections.singletonMap("ok", true); } + static Map explicitConsumerGroupOffsets(JsonObject params, String topic) { + Map offsets = new HashMap<>(); + if (!params.has("offsets")) { + return offsets; + } + JsonElement offsetsElement = params.get("offsets"); + if (!offsetsElement.isJsonArray()) { + throw new IllegalArgumentException("offsets must be an array"); + } + JsonArray offsetArray = offsetsElement.getAsJsonArray(); + if (offsetArray.isEmpty()) { + throw new IllegalArgumentException("offsets must contain at least one partition offset"); + } + for (JsonElement element : offsetArray) { + if (!element.isJsonObject()) { + throw new IllegalArgumentException("each offset must be an object"); + } + JsonObject value = element.getAsJsonObject(); + int partition = nonNegativeExactInt(value, "partition"); + long offset = nonNegativeExactLong(value, "offset"); + TopicPartition topicPartition = new TopicPartition(topic, partition); + if (offsets.put(topicPartition, new OffsetAndMetadata(offset)) != null) { + throw new IllegalArgumentException("duplicate partition in offsets: " + partition); + } + } + return offsets; + } + + private static int nonNegativeExactInt(JsonObject object, String name) { + long value = nonNegativeExactLong(object, name); + if (value > Integer.MAX_VALUE) { + throw new IllegalArgumentException(name + " is outside the supported integer range"); + } + return (int) value; + } + + private static long nonNegativeExactLong(JsonObject object, String name) { + JsonElement element = object.get(name); + if (element == null || !element.isJsonPrimitive() || !element.getAsJsonPrimitive().isNumber()) { + throw new IllegalArgumentException(name + " must be a non-negative integer"); + } + try { + java.math.BigDecimal decimal = element.getAsBigDecimal(); + if (decimal.signum() < 0 || decimal.stripTrailingZeros().scale() > 0) { + throw new IllegalArgumentException(name + " must be a non-negative integer"); + } + return decimal.longValueExact(); + } catch (ArithmeticException error) { + throw new IllegalArgumentException(name + " is outside the supported integer range", error); + } + } + static OffsetSpec offsetSpecForPosition(String position, Long timestampMs) { String normalized = position == null ? "latest" : position.trim().toLowerCase(Locale.ROOT); return switch (normalized) { diff --git a/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java b/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java index 360d079a32..8f0ec8176a 100644 --- a/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java +++ b/agents/drivers/kafka/src/test/java/com/dbx/agent/kafka/KafkaAgentTest.java @@ -54,6 +54,40 @@ class KafkaAgentTest { @TempDir Path tempDir; + @Test + void explicitConsumerGroupOffsetsAcceptOneBoundedPartitionOffset() { + JsonObject params = JsonParser.parseString(""" + {"offsets":[{"partition":1,"offset":42}]} + """).getAsJsonObject(); + + assertEquals( + Map.of(new TopicPartition("events", 1), new org.apache.kafka.clients.consumer.OffsetAndMetadata(42L)), + KafkaAgent.explicitConsumerGroupOffsets(params, "events") + ); + } + + @Test + void explicitConsumerGroupOffsetsRejectMalformedOrNegativeValues() { + for (String json : List.of( + "{\"offsets\":[{\"partition\":-1,\"offset\":42}]}", + "{\"offsets\":[{\"partition\":1,\"offset\":-1}]}", + "{\"offsets\":[{\"partition\":1.5,\"offset\":42}]}", + "{\"offsets\":[{\"partition\":1,\"offset\":42.5}]}", + "{\"offsets\":[{\"partition\":\"1\",\"offset\":42}]}", + "{\"offsets\":[{\"partition\":1,\"offset\":\"42\"}]}", + "{\"offsets\":[{\"partition\":1}]}", + "{\"offsets\":[42]}", + "{\"offsets\":[{\"partition\":2147483648,\"offset\":42}]}", + "{\"offsets\":[{\"partition\":1,\"offset\":9223372036854775808}]}", + "{\"offsets\":[{\"partition\":1,\"offset\":42},{\"partition\":1,\"offset\":43}]}", + "{\"offsets\":[]}", + "{\"offsets\":{}}" + )) { + JsonObject params = JsonParser.parseString(json).getAsJsonObject(); + assertThrows(IllegalArgumentException.class, () -> KafkaAgent.explicitConsumerGroupOffsets(params, "events"), json); + } + } + @Test void topicListingFallsBackWhenDescriptionsAreUnsupported() throws Exception { Object result = KafkaAgent.topicListResult( diff --git a/apps/desktop/src/components/mq/SubscriptionsPanel.vue b/apps/desktop/src/components/mq/SubscriptionsPanel.vue index 4eb12238de..b8db362771 100644 --- a/apps/desktop/src/components/mq/SubscriptionsPanel.vue +++ b/apps/desktop/src/components/mq/SubscriptionsPanel.vue @@ -65,8 +65,10 @@ const formData = ref({ }); const resetFormData = ref({ - position: "latest" as "earliest" | "latest" | "timestamp", + position: "latest" as "earliest" | "latest" | "timestamp" | "partitionOffset", timestampMs: Date.now(), + partition: 0, + offset: 0, }); const skipFormData = ref({ @@ -261,6 +263,8 @@ function openResetDialog(sub: SubscriptionInfo) { resetFormData.value = { position: "latest", timestampMs: Date.now(), + partition: 0, + offset: 0, }; showResetDialog.value = true; } @@ -365,6 +369,13 @@ async function handleResetCursor() { let pos: ResetPosition; if (resetFormData.value.position === "timestamp") { pos = { kind: "timestamp", timestampMs: resetFormData.value.timestampMs }; + } else if (resetFormData.value.position === "partitionOffset") { + const { partition, offset } = resetFormData.value; + if (!Number.isSafeInteger(partition) || partition < 0 || !Number.isSafeInteger(offset) || offset < 0) { + error.value = t("mqSubscriptions.nonNegativeIntegerRequired"); + return; + } + pos = { kind: "partitionOffset", partition, offset }; } else { pos = { kind: resetFormData.value.position }; } @@ -650,6 +661,10 @@ watch( {{ t("mqSubscriptions.timestamp") }} +
@@ -657,6 +672,12 @@ watch(
{{ t("mqSubscriptions.currentTime", { time: new Date(resetFormData.timestampMs).toLocaleString() }) }}
+
+ + + + +
{{ error }}