diff --git a/.gitignore b/.gitignore index 42f611bb67..5dc6f08a94 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,7 @@ __pycache__ bin/ !/bin/ release/.venv/ + +# Claude Code project config +.claude/settings.local.json +.claude/plans/ diff --git a/bin/kafka-cluster-events.sh b/bin/kafka-cluster-events.sh new file mode 100755 index 0000000000..b9ce15ca13 --- /dev/null +++ b/bin/kafka-cluster-events.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +exec $(dirname $0)/kafka-run-class.sh org.apache.kafka.tools.ClusterEventsCommand "$@" diff --git a/build.gradle b/build.gradle index 3368733737..b9622f9ae2 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ plugins { // Updating the shadow plugin version to 8.1.1 causes issue with signing and publishing the shadowed // artifacts - see https://github.com/johnrengelman/shadow/issues/901 id 'com.github.johnrengelman.shadow' version '8.1.0' apply false + id 'com.google.protobuf' version '0.9.4' apply false // Spotless 6.13.0 has issue with Java 21 (see https://github.com/diffplug/spotless/pull/1920), and Spotless 6.14.0+ requires JRE 11 // We are going to drop JDK8 support. Hence, the spotless is upgrade to newest version and be applied only if the build env is compatible with JDK 11. // spotless 6.15.0+ has issue in runtime with JDK8 even through we define it with `apply:false`. see https://github.com/diffplug/spotless/issues/2156 for more details @@ -157,6 +158,16 @@ allprojects { // that are unrelated to the project dependencies, we should not change them if (name != "zinc") { resolutionStrategy { + // AutoMQ inject start + eachDependency { details -> + if (details.requested.group == 'io.netty' && + details.requested.name != 'netty' && + !details.requested.name.startsWith('netty-tcnative')) { + details.useVersion versions.netty + details.because('Release packaging must align all Netty modules to a single version') + } + } + // AutoMQ inject end force( // be explicit about the javassist dependency version instead of relying on the transitive version libs.javassist, @@ -169,8 +180,19 @@ allprojects { libs.nettyHandler, libs.nettyTransportNativeEpoll, // be explicit about the reload4j version instead of relying on the transitive versions - libs.reload4j + libs.reload4j, + // AutoMQ inject start + // pin OTel sender versions to prevent version conflicts with Strimzi thirdparty-libs + libs.opentelemetryExporterSenderJdk, + libs.opentelemetryExporterSenderGrpcManagedChannel + // AutoMQ inject end ) + // AutoMQ inject start + // swagger is only used for doc generation and still depends on jackson-yaml compatible with SnakeYAML 1.x. + if (name != "swagger") { + force(libs.snakeyaml) + } + // AutoMQ inject end } } } @@ -768,7 +790,7 @@ subprojects { apply plugin: 'com.diffplug.spotless' spotless { java { - targetExclude('src/generated/**/*.java','src/generated-test/**/*.java') + targetExclude('src/generated/**/*.java', 'src/generated-test/**/*.java', 'build/generated/**/*.java') importOrder('kafka', 'org.apache.kafka', 'com', 'net', 'org', 'java', 'javax', '', '\\#') removeUnusedImports() } @@ -993,7 +1015,7 @@ project(':core') { // table topic start implementation ("org.apache.avro:avro:${versions.avro}") implementation ("org.apache.avro:avro-protobuf:${versions.avro}") - implementation('com.google.protobuf:protobuf-java:3.25.5') + implementation libs.protobuf implementation ("org.apache.iceberg:iceberg-core:${versions.iceberg}") implementation ("org.apache.iceberg:iceberg-api:${versions.iceberg}") implementation ("org.apache.iceberg:iceberg-data:${versions.iceberg}") @@ -1003,7 +1025,7 @@ project(':core') { implementation ("org.apache.iceberg:iceberg-nessie:${versions.iceberg}") implementation ("software.amazon.awssdk:glue:${versions.awsSdk}") implementation ("software.amazon.awssdk:s3tables:${versions.awsSdk}") - implementation 'software.amazon.s3tables:s3-tables-catalog-for-iceberg:0.1.0' + implementation 'software.amazon.s3tables:s3-tables-catalog-for-iceberg:0.1.8' implementation ('org.apache.hadoop:hadoop-common:3.4.1') { exclude group: 'org.eclipse.jetty', module: '*' @@ -1051,7 +1073,7 @@ project(':core') { // Wire Runtime for schema handling implementation ("com.squareup.wire:wire-schema:${versions.wire}") implementation ("com.squareup.wire:wire-runtime:${versions.wire}") - implementation 'com.google.api.grpc:proto-google-common-protos:2.52.0' + implementation libs.protoGoogleCommonProtos // > Protobuf ext end // table topic end @@ -1683,6 +1705,8 @@ project(':generator') { } project(':clients') { + apply plugin: 'com.google.protobuf' + base { archivesName = "kafka-clients" } @@ -1700,6 +1724,10 @@ project(':clients') { implementation libs.opentelemetryProto implementation libs.protobuf + // AutoMQ inject start + implementation libs.cloudeventsKafka + // AutoMQ inject end + // libraries which should be added as runtime dependencies in generated pom.xml should be defined here: shadowed libs.zstd shadowed libs.lz4 @@ -1818,7 +1846,7 @@ project(':clients') { sourceSets { main { java { - srcDirs = ["src/generated/java", "src/main/java"] + srcDirs = ["src/generated/java", "src/main/java", "$buildDir/generated/source/proto/main/java"] // AutoMQ: add proto source } } test { @@ -1828,7 +1856,28 @@ project(':clients') { } } + protobuf { + protoc { + artifact = "com.google.protobuf:protoc:$versions.protobuf" + } + } + + // Exclude protobuf-generated sources from checkstyle and spotless + afterEvaluate { + checkstyleMain.source = checkstyleMain.source.filter { !it.path.contains('/generated/source/proto/') } + if (tasks.findByName('spotlessJava')) { + spotless { + java { + targetExclude('build/generated/source/proto/**/*.java') + } + } + } + } + compileJava.dependsOn 'processMessages' + // AutoMQ inject start: ensure proto sources are generated before compilation + compileJava.dependsOn 'generateProto' + // AutoMQ inject end srcJar.dependsOn 'processMessages' compileTestJava.dependsOn 'processTestMessages' @@ -2363,6 +2412,7 @@ project(':automq-metrics') { api libs.opentelemetryExporterLogging api libs.opentelemetryExporterProm api libs.opentelemetryExporterOTLP + api libs.opentelemetryExporterSenderJdk api libs.opentelemetryJmx // Logging dependencies @@ -2404,7 +2454,7 @@ project(':automq-metrics') { implementation('io.opentelemetry:opentelemetry-sdk:1.40.0') implementation("io.opentelemetry.semconv:opentelemetry-semconv:1.25.0-alpha") implementation("io.opentelemetry.instrumentation:opentelemetry-runtime-telemetry-java8:2.6.0-alpha") - implementation('com.google.protobuf:protobuf-java:3.25.5') + implementation libs.protobuf implementation('org.xerial.snappy:snappy-java:1.1.10.5') } @@ -2474,7 +2524,11 @@ project(':tools') { exclude group: 'org.apache.kafka', module: 'kafka-clients' } implementation libs.bucket4j - implementation libs.oshi + implementation (libs.oshi) { + exclude group: 'org.slf4j', module: 'slf4j-api' + } + implementation libs.cloudeventsKafka + implementation libs.protobuf // AutoMQ inject end // for SASL/OAUTHBEARER JWT validation diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java index b634366d1e..f93e97bffc 100644 --- a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java +++ b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java @@ -1757,6 +1757,14 @@ default ExportClusterManifestResult exportClusterManifest() { } ExportClusterManifestResult exportClusterManifest(ExportClusterManifestOptions options); + + /** + * Create a reader for the {@code __automq_cluster_events} internal topic. + * + * @param sinceMs only return events at or after this epoch-millisecond timestamp, or null for all + * @return a {@link ClusterEventsReader} that must be closed when done + */ + ClusterEventsReader describeClusterEvents(Long sinceMs); // AutoMQ inject end /** diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventPublisher.java b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventPublisher.java new file mode 100644 index 0000000000..53de04ea05 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventPublisher.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.internals.Topic; +import org.apache.kafka.common.serialization.StringSerializer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; +import io.cloudevents.kafka.CloudEventSerializer; + +/** + * Publishes cluster events to the {@code __automq_cluster_events} internal topic. + * + *

Events are written asynchronously and best-effort: if the producer fails to send an event, + * a warning is logged and the event is dropped. Events must never block or fail the critical path + * (rebalancing, failover, request handling). + * + *

Example usage: + *

{@code
+ * // At broker startup
+ * ClusterEventPublisher.setup(Map.of("bootstrap.servers", "localhost:9092"));
+ *
+ * // From any code path
+ * ClusterEventPublisher.publish(
+ *     "com.automq.risk.request_error",
+ *     "/automq/broker/0",
+ *     "PRODUCE:my-topic",
+ *     "com.automq.events.RequestErrorEvent",
+ *     requestErrorEvent.toByteArray());
+ *
+ * // At shutdown
+ * ClusterEventPublisher.shutdown();
+ * }
+ */ +public class ClusterEventPublisher implements IClusterEventPublisher { + + private static final Logger log = LoggerFactory.getLogger(ClusterEventPublisher.class); + + private static final AtomicReference INSTANCE = + new AtomicReference<>(NoopClusterEventPublisher.INSTANCE); + + private final KafkaProducer producer; + private final AtomicBoolean closed = new AtomicBoolean(false); + + /** + * Create a publisher from a config map. The map should contain at least + * {@link ProducerConfig#BOOTSTRAP_SERVERS_CONFIG}. Any additional producer or security + * properties (e.g. SASL/SSL) can be included directly. Serializer, acks, retries, + * batch.size and linger.ms are set with sensible defaults if not provided. + * + * @param config producer configuration map + */ + private ClusterEventPublisher(Map config) { + Map props = new HashMap<>(config); + props.putIfAbsent(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); + props.putIfAbsent(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, CloudEventSerializer.class.getName()); + props.putIfAbsent(ProducerConfig.BATCH_SIZE_CONFIG, ClusterEventsConfig.DEFAULT_PUBLISHER_BATCH_SIZE); + props.putIfAbsent(ProducerConfig.LINGER_MS_CONFIG, ClusterEventsConfig.DEFAULT_PUBLISHER_LINGER_MS); + props.putIfAbsent(ProducerConfig.ACKS_CONFIG, "1"); + props.putIfAbsent(ProducerConfig.RETRIES_CONFIG, 3); + + this.producer = new KafkaProducer<>(props); + } + + // ---- Global singleton ---- + + /** + * Initialize the global singleton publisher. If already set up, the previous instance + * is closed and replaced. + * + * @param config producer configuration map + */ + public static void setup(Map config) { + IClusterEventPublisher prev = INSTANCE.getAndSet(new ClusterEventPublisher(config)); + if (prev != null) { + prev.close(); + } + } + + /** + * Shut down the global singleton publisher, reverting to the no-op instance. + */ + public static void shutdown() { + IClusterEventPublisher prev = INSTANCE.getAndSet(NoopClusterEventPublisher.INSTANCE); + if (prev != null) { + prev.close(); + } + } + + /** + * Publish an event via the global singleton. If {@link #setup(Map)} has not been called, + * this is a no-op. + */ + public static void publish(String type, String source, String subject, String dataSchema, byte[] data) { + INSTANCE.get().publishEvent(type, source, subject, dataSchema, data); + } + + // ---- Instance methods ---- + + @Override + public void publishEvent(String type, String source, String subject, String dataSchema, byte[] data) { + if (closed.get()) { + log.warn("ClusterEventPublisher is closed, dropping event type={}", type); + return; + } + + CloudEvent event = buildEvent(type, source, subject, dataSchema, data); + String key = type + ":" + event.getId(); + ProducerRecord record = + new ProducerRecord<>(Topic.CLUSTER_EVENTS_TOPIC_NAME, key, event); + + producer.send(record, (metadata, exception) -> { + if (exception != null) { + log.warn("Failed to publish cluster event type={}, dropping: {}", + record.value().getType(), exception.getMessage()); + } + }); + } + + private CloudEvent buildEvent(String type, String source, String subject, String dataSchema, byte[] data) { + CloudEventBuilder builder = CloudEventBuilder.v1() + .withId(UUID.randomUUID().toString()) + .withType(type) + .withSource(URI.create(source)) + .withTime(OffsetDateTime.now()) + .withDataContentType("application/protobuf") + .withDataSchema(URI.create(dataSchema)) + .withData("application/protobuf", data); + + if (subject != null) { + builder = builder.withSubject(subject); + } + + return builder.build(); + } + + @Override + public void close() { + if (closed.compareAndSet(false, true)) { + producer.close(); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventTypeRegistry.java b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventTypeRegistry.java new file mode 100644 index 0000000000..a7dd1b9219 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventTypeRegistry.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import io.cloudevents.CloudEvent; +import io.cloudevents.CloudEventData; + +/** + * Maps CloudEvent {@code dataschema} values to their POJO wrappers. + * + *

Usage: + *

{@code
+ * ClusterEventTypeRegistry.decode(event).ifPresent(obj -> {
+ *     if (obj instanceof RebalanceSummaryEventData summary) { ... }
+ *     else if (obj instanceof FailoverEventData failover) { ... }
+ * });
+ * }
+ */ +public class ClusterEventTypeRegistry { + + private static final Logger log = LoggerFactory.getLogger(ClusterEventTypeRegistry.class); + + private static final Map> DECODERS = Map.of( + RebalanceSummaryEventData.DATA_SCHEMA, b -> RebalanceSummaryEventData.fromByteArray(b), + RebalancePartitionEventData.DATA_SCHEMA, b -> RebalancePartitionEventData.fromByteArray(b), + FailoverEventData.DATA_SCHEMA, b -> FailoverEventData.fromByteArray(b), + RequestErrorEventData.DATA_SCHEMA, b -> RequestErrorEventData.fromByteArray(b), + OffsetCommitFrequencyEventData.DATA_SCHEMA, b -> OffsetCommitFrequencyEventData.fromByteArray(b) + ); + + private ClusterEventTypeRegistry() { } + + /** + * Decode the protobuf payload of a CloudEvent into its POJO wrapper. + * + * @return the decoded wrapper, or empty if the dataschema is unknown or the payload is invalid + */ + public static Optional decode(CloudEvent event) { + URI dataSchema = event.getDataSchema(); + CloudEventData data = event.getData(); + if (dataSchema == null || data == null) { + return Optional.empty(); + } + Function decoder = DECODERS.get(dataSchema.toString()); + if (decoder == null) { + return Optional.empty(); + } + try { + return Optional.of(decoder.apply(data.toBytes())); + } catch (Exception e) { + log.debug("Failed to decode CloudEvent payload for dataschema={}", dataSchema, e); + return Optional.empty(); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventsConfig.java b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventsConfig.java new file mode 100644 index 0000000000..0edde8027a --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventsConfig.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +/** + * Constants for the cluster events system. + */ +public class ClusterEventsConfig { + + public static final int DEFAULT_PARTITIONS = 1; + public static final long DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000L; // 7 days + public static final int DEFAULT_PUBLISHER_BATCH_SIZE = 16384; + public static final long DEFAULT_PUBLISHER_LINGER_MS = 100L; +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventsReader.java b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventsReader.java new file mode 100644 index 0000000000..d0c16fdde8 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/ClusterEventsReader.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.consumer.OffsetAndTimestamp; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.internals.Topic; +import org.apache.kafka.common.serialization.StringDeserializer; + +import java.io.Closeable; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +import io.cloudevents.CloudEvent; +import io.cloudevents.kafka.CloudEventDeserializer; + +/** + * Reads events from the {@code __automq_cluster_events} internal topic. + * + *

Supports both one-shot reads and continuous polling (tail mode). + * Call {@link #poll(Duration)} repeatedly to consume new events as they arrive. + * Must be {@link #close() closed} when done. + */ +public class ClusterEventsReader implements Closeable { + + private static final TopicPartition TP = + new TopicPartition(Topic.CLUSTER_EVENTS_TOPIC_NAME, 0); + + private final KafkaConsumer consumer; + private long endOffset; + + ClusterEventsReader(Map consumerConfig, Long sinceMs) { + this.consumer = createConsumer(consumerConfig); + consumer.assign(Collections.singletonList(TP)); + seek(sinceMs); + refreshEndOffset(); + } + + /** + * Poll for new events. + * + * @return events received within the timeout, may be empty + */ + public List poll(Duration timeout) { + List results = new ArrayList<>(); + for (ConsumerRecord record : consumer.poll(timeout)) { + if (record.value() != null) { + results.add(record.value()); + } + } + return results; + } + + /** + * Returns true if the consumer position has reached the end offset + * that was snapshotted when this reader was created. + */ + public boolean isPolledToEnd() { + return consumer.position(TP) >= endOffset; + } + + @Override + public void close() { + consumer.close(); + } + + private void refreshEndOffset() { + Long end = consumer.endOffsets(Collections.singletonList(TP)).get(TP); + this.endOffset = end != null ? end : 0L; + } + + private void seek(Long sinceMs) { + if (sinceMs != null) { + Map offsets = + consumer.offsetsForTimes(Collections.singletonMap(TP, sinceMs)); + OffsetAndTimestamp ot = offsets.get(TP); + if (ot != null) { + consumer.seek(TP, ot.offset()); + } else { + consumer.seekToEnd(Collections.singletonList(TP)); + } + } else { + consumer.seekToBeginning(Collections.singletonList(TP)); + } + } + + private static KafkaConsumer createConsumer(Map config) { + Properties props = new Properties(); + props.putAll(config); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, CloudEventDeserializer.class.getName()); + props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false"); + return new KafkaConsumer<>(props); + } + + static Map buildConsumerConfig(AdminClientConfig config) { + Map result = new java.util.HashMap<>(); + result.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, + String.join(",", config.getList(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG))); + for (Map.Entry entry : config.values().entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (value != null && (key.startsWith("ssl.") || key.startsWith("sasl.") + || key.equals("security.protocol"))) { + result.put(key, value); + } + } + return result; + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/FailoverEventData.java b/clients/src/main/java/org/apache/kafka/clients/admin/FailoverEventData.java new file mode 100644 index 0000000000..e4435d1442 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/FailoverEventData.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kafka.clients.admin; + +import com.automq.events.FailoverEvent; + +/** + * POJO wrapper for {@link FailoverEvent} protobuf message. + */ +public class FailoverEventData { + + public static final String TYPE = "com.automq.ops.failover"; + public static final String DATA_SCHEMA = FailoverEvent.getDescriptor().getFullName(); + + private int failedNodeId; + private long detectedTimestamp; + private long completedTimestamp; + private String detail; + + public FailoverEventData setFailedNodeId(int failedNodeId) { + this.failedNodeId = failedNodeId; + return this; + } + + public FailoverEventData setDetectedTimestamp(long detectedTimestamp) { + this.detectedTimestamp = detectedTimestamp; + return this; + } + + public FailoverEventData setCompletedTimestamp(long completedTimestamp) { + this.completedTimestamp = completedTimestamp; + return this; + } + + public FailoverEventData setDetail(String detail) { + this.detail = detail; + return this; + } + + public int failedNodeId() { + return failedNodeId; + } + + public long detectedTimestamp() { + return detectedTimestamp; + } + + public long completedTimestamp() { + return completedTimestamp; + } + + public String detail() { + return detail; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("FailoverEvent{"); + sb.append("failedNodeId=").append(failedNodeId); + sb.append(", detectedTimestamp=").append(detectedTimestamp); + sb.append(", completedTimestamp=").append(completedTimestamp); + if (detail != null) sb.append(", detail='").append(detail).append('\''); + sb.append('}'); + return sb.toString(); + } + + public byte[] toByteArray() { + FailoverEvent.Builder b = FailoverEvent.newBuilder() + .setFailedNodeId(failedNodeId) + .setDetectedTimestamp(detectedTimestamp) + .setCompletedTimestamp(completedTimestamp); + if (detail != null) b.setDetail(detail); + return b.build().toByteArray(); + } + + public static FailoverEventData fromByteArray(byte[] data) { + try { + FailoverEvent e = FailoverEvent.parseFrom(data); + return new FailoverEventData() + .setFailedNodeId(e.getFailedNodeId()) + .setDetectedTimestamp(e.getDetectedTimestamp()) + .setCompletedTimestamp(e.getCompletedTimestamp()) + .setDetail(e.getDetail()); + } catch (Exception ex) { + throw new IllegalArgumentException("Failed to parse FailoverEvent", ex); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ForwardingAdmin.java b/clients/src/main/java/org/apache/kafka/clients/admin/ForwardingAdmin.java index 5b61e99ea2..e3d1bba931 100644 --- a/clients/src/main/java/org/apache/kafka/clients/admin/ForwardingAdmin.java +++ b/clients/src/main/java/org/apache/kafka/clients/admin/ForwardingAdmin.java @@ -335,5 +335,10 @@ public ExportClusterManifestResult exportClusterManifest(ExportClusterManifestOp return delegate.exportClusterManifest(options); } + @Override + public ClusterEventsReader describeClusterEvents(Long sinceMs) { + return delegate.describeClusterEvents(sinceMs); + } + // AutoMQ inject end } diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/IClusterEventPublisher.java b/clients/src/main/java/org/apache/kafka/clients/admin/IClusterEventPublisher.java new file mode 100644 index 0000000000..116bc610da --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/IClusterEventPublisher.java @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +/** + * Interface for publishing cluster events to the {@code __automq_cluster_events} internal topic. + */ +public interface IClusterEventPublisher extends AutoCloseable { + + /** + * Publish a domain event to {@code __automq_cluster_events}. + * + * @param type CloudEvent type (reverse-DNS), e.g. {@code "com.automq.ops.rebalance.summary"} + * @param source CloudEvent source URI, e.g. {@code "/automq/broker/0"} + * @param subject CloudEvent subject, or {@code null} if not applicable + * @param dataSchema fully-qualified protobuf message type name + * @param data serialized protobuf payload + */ + void publishEvent(String type, String source, String subject, String dataSchema, byte[] data); + + @Override + void close(); +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java b/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java index addb4ba66c..2bfc4dfaf3 100644 --- a/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java +++ b/clients/src/main/java/org/apache/kafka/clients/admin/KafkaAdminClient.java @@ -420,6 +420,10 @@ public class KafkaAdminClient extends AdminClient { private final boolean clientTelemetryEnabled; private final MetadataRecoveryStrategy metadataRecoveryStrategy; + // AutoMQ inject start + private final AdminClientConfig adminClientConfig; + // AutoMQ inject end + /** * The telemetry requests client instance id. */ @@ -633,6 +637,9 @@ private KafkaAdminClient(AdminClientConfig config, CommonClientConfigs.RETRY_BACKOFF_JITTER); this.clientTelemetryEnabled = config.getBoolean(AdminClientConfig.ENABLE_METRICS_PUSH_CONFIG); this.metadataRecoveryStrategy = MetadataRecoveryStrategy.forName(config.getString(AdminClientConfig.METADATA_RECOVERY_STRATEGY_CONFIG)); + // AutoMQ inject start + this.adminClientConfig = config; + // AutoMQ inject end config.logUnused(); AppInfoParser.registerAppInfo(JMX_PREFIX, clientId, metrics, time.milliseconds()); log.debug("Kafka admin client initialized"); @@ -2458,7 +2465,7 @@ private TopicDescription getTopicDescriptionFromDescribeTopicsResponseTopic( return new TopicDescription(topic.name(), topic.isInternal(), partitions, authorisedOperations, topic.topicId()); } - // AutoMQ for Kafka inject start + // AutoMQ inject start @Override public GetNextNodeIdResult getNextNodeId(GetNextNodeIdOptions options) { KafkaFutureImpl nodeIdFuture = new KafkaFutureImpl<>(); @@ -2615,7 +2622,12 @@ void handleFailure(Throwable throwable) { runnable.call(call, now); return new ExportClusterManifestResult(future); } - // AutoMQ for Kafka inject end + + @Override + public ClusterEventsReader describeClusterEvents(Long sinceMs) { + return new ClusterEventsReader(ClusterEventsReader.buildConsumerConfig(adminClientConfig), sinceMs); + } + // AutoMQ inject end private TopicDescription getTopicDescriptionFromCluster(Cluster cluster, String topicName, Uuid topicId, Integer authorizedOperations) { diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/NoopClusterEventPublisher.java b/clients/src/main/java/org/apache/kafka/clients/admin/NoopClusterEventPublisher.java new file mode 100644 index 0000000000..bc71bbe4ab --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/NoopClusterEventPublisher.java @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +/** + * No-op implementation used when the publisher has not been set up. + * All events are silently dropped. + */ +public class NoopClusterEventPublisher implements IClusterEventPublisher { + + static final NoopClusterEventPublisher INSTANCE = new NoopClusterEventPublisher(); + + @Override + public void publishEvent(String type, String source, String subject, String dataSchema, byte[] data) { + // no-op + } + + @Override + public void close() { + // no-op + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/OffsetCommitFrequencyEventData.java b/clients/src/main/java/org/apache/kafka/clients/admin/OffsetCommitFrequencyEventData.java new file mode 100644 index 0000000000..8d4dbd2625 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/OffsetCommitFrequencyEventData.java @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kafka.clients.admin; + +import com.automq.events.OffsetCommitFrequencyEvent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * POJO wrapper for {@link OffsetCommitFrequencyEvent} protobuf message. + */ +public class OffsetCommitFrequencyEventData { + + public static final String TYPE = "com.automq.risk.offset_commit_frequency"; + public static final String DATA_SCHEMA = OffsetCommitFrequencyEvent.getDescriptor().getFullName(); + + private List clientIps = Collections.emptyList(); + private List clientIds = Collections.emptyList(); + private String groupId; + private String topic; + private double rps; + + public OffsetCommitFrequencyEventData setClientIps(List clientIps) { + this.clientIps = clientIps != null ? clientIps : Collections.emptyList(); + return this; + } + + public OffsetCommitFrequencyEventData setClientIds(List clientIds) { + this.clientIds = clientIds != null ? clientIds : Collections.emptyList(); + return this; + } + + public OffsetCommitFrequencyEventData setGroupId(String groupId) { + this.groupId = groupId; + return this; + } + + public OffsetCommitFrequencyEventData setTopic(String topic) { + this.topic = topic; + return this; + } + + public OffsetCommitFrequencyEventData setRps(double rps) { + this.rps = rps; + return this; + } + + public List clientIps() { + return clientIps; + } + + public List clientIds() { + return clientIds; + } + + public String groupId() { + return groupId; + } + + public String topic() { + return topic; + } + + public double rps() { + return rps; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("OffsetCommitFrequencyEvent{"); + if (!clientIps.isEmpty()) sb.append("clientIps=").append(clientIps).append(", "); + if (!clientIds.isEmpty()) sb.append("clientIds=").append(clientIds).append(", "); + sb.append("groupId='").append(groupId).append('\''); + sb.append(", topic='").append(topic).append('\''); + sb.append(", rps=").append(rps); + sb.append('}'); + return sb.toString(); + } + + public byte[] toByteArray() { + OffsetCommitFrequencyEvent.Builder b = OffsetCommitFrequencyEvent.newBuilder() + .addAllClientIps(clientIps) + .addAllClientIds(clientIds) + .setRps(rps); + if (groupId != null) b.setGroupId(groupId); + if (topic != null) b.setTopic(topic); + return b.build().toByteArray(); + } + + public static OffsetCommitFrequencyEventData fromByteArray(byte[] data) { + try { + OffsetCommitFrequencyEvent e = OffsetCommitFrequencyEvent.parseFrom(data); + return new OffsetCommitFrequencyEventData() + .setClientIps(new ArrayList<>(e.getClientIpsList())) + .setClientIds(new ArrayList<>(e.getClientIdsList())) + .setGroupId(e.getGroupId()) + .setTopic(e.getTopic()) + .setRps(e.getRps()); + } catch (Exception ex) { + throw new IllegalArgumentException("Failed to parse OffsetCommitFrequencyEvent", ex); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/RebalancePartitionEventData.java b/clients/src/main/java/org/apache/kafka/clients/admin/RebalancePartitionEventData.java new file mode 100644 index 0000000000..c38c444e5e --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/RebalancePartitionEventData.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kafka.clients.admin; + +import com.automq.events.RebalancePartitionEvent; + +/** + * POJO wrapper for {@link RebalancePartitionEvent} protobuf message. + */ +public class RebalancePartitionEventData { + + public static final String TYPE = "com.automq.ops.rebalance.partition"; + public static final String DATA_SCHEMA = RebalancePartitionEvent.getDescriptor().getFullName(); + + private String rebalanceId; + private String topicPartition; + private int fromBroker; + private int toBroker; + private String reason; + + public RebalancePartitionEventData setRebalanceId(String rebalanceId) { + this.rebalanceId = rebalanceId; + return this; + } + + public RebalancePartitionEventData setTopicPartition(String topicPartition) { + this.topicPartition = topicPartition; + return this; + } + + public RebalancePartitionEventData setFromBroker(int fromBroker) { + this.fromBroker = fromBroker; + return this; + } + + public RebalancePartitionEventData setToBroker(int toBroker) { + this.toBroker = toBroker; + return this; + } + + public RebalancePartitionEventData setReason(String reason) { + this.reason = reason; + return this; + } + + public String rebalanceId() { + return rebalanceId; + } + + public String topicPartition() { + return topicPartition; + } + + public int fromBroker() { + return fromBroker; + } + + public int toBroker() { + return toBroker; + } + + public String reason() { + return reason; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("RebalancePartitionEvent{"); + sb.append("rebalanceId='").append(rebalanceId).append('\''); + sb.append(", topicPartition='").append(topicPartition).append('\''); + sb.append(", fromBroker=").append(fromBroker); + sb.append(", toBroker=").append(toBroker); + if (reason != null) sb.append(", reason='").append(reason).append('\''); + sb.append('}'); + return sb.toString(); + } + + public byte[] toByteArray() { + RebalancePartitionEvent.Builder b = RebalancePartitionEvent.newBuilder(); + if (rebalanceId != null) b.setRebalanceId(rebalanceId); + if (topicPartition != null) b.setTopicPartition(topicPartition); + b.setFromBroker(fromBroker); + b.setToBroker(toBroker); + if (reason != null) b.setReason(reason); + return b.build().toByteArray(); + } + + public static RebalancePartitionEventData fromByteArray(byte[] data) { + try { + RebalancePartitionEvent e = RebalancePartitionEvent.parseFrom(data); + return new RebalancePartitionEventData() + .setRebalanceId(e.getRebalanceId()) + .setTopicPartition(e.getTopicPartition()) + .setFromBroker(e.getFromBroker()) + .setToBroker(e.getToBroker()) + .setReason(e.getReason()); + } catch (Exception ex) { + throw new IllegalArgumentException("Failed to parse RebalancePartitionEvent", ex); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/RebalanceSummaryEventData.java b/clients/src/main/java/org/apache/kafka/clients/admin/RebalanceSummaryEventData.java new file mode 100644 index 0000000000..91569232c7 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/RebalanceSummaryEventData.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kafka.clients.admin; + +import com.automq.events.RebalanceSummaryEvent; + +import java.util.HashMap; +import java.util.Map; + +/** + * POJO wrapper for {@link RebalanceSummaryEvent} protobuf message. + */ +public class RebalanceSummaryEventData { + + public static final String TYPE = "com.automq.ops.rebalance.summary"; + public static final String DATA_SCHEMA = RebalanceSummaryEvent.getDescriptor().getFullName(); + + private String rebalanceId; + private String triggerReason; + private int partitionCount; + private Map brokerLoadBefore = new HashMap<>(); + private Map brokerLoadAfter = new HashMap<>(); + + public RebalanceSummaryEventData setRebalanceId(String rebalanceId) { + this.rebalanceId = rebalanceId; + return this; + } + + public RebalanceSummaryEventData setTriggerReason(String triggerReason) { + this.triggerReason = triggerReason; + return this; + } + + public RebalanceSummaryEventData setPartitionCount(int partitionCount) { + this.partitionCount = partitionCount; + return this; + } + + public RebalanceSummaryEventData setBrokerLoadBefore(Map brokerLoadBefore) { + this.brokerLoadBefore = brokerLoadBefore != null ? brokerLoadBefore : new HashMap<>(); + return this; + } + + public RebalanceSummaryEventData setBrokerLoadAfter(Map brokerLoadAfter) { + this.brokerLoadAfter = brokerLoadAfter != null ? brokerLoadAfter : new HashMap<>(); + return this; + } + + public String rebalanceId() { + return rebalanceId; + } + + public String triggerReason() { + return triggerReason; + } + + public int partitionCount() { + return partitionCount; + } + + public Map brokerLoadBefore() { + return brokerLoadBefore; + } + + public Map brokerLoadAfter() { + return brokerLoadAfter; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("RebalanceSummaryEvent{"); + sb.append("rebalanceId='").append(rebalanceId).append('\''); + sb.append(", triggerReason='").append(triggerReason).append('\''); + sb.append(", partitionCount=").append(partitionCount); + if (!brokerLoadBefore.isEmpty()) sb.append(", brokerLoadBefore=").append(brokerLoadBefore); + if (!brokerLoadAfter.isEmpty()) sb.append(", brokerLoadAfter=").append(brokerLoadAfter); + sb.append('}'); + return sb.toString(); + } + + public byte[] toByteArray() { + RebalanceSummaryEvent.Builder b = RebalanceSummaryEvent.newBuilder(); + if (rebalanceId != null) b.setRebalanceId(rebalanceId); + if (triggerReason != null) b.setTriggerReason(triggerReason); + b.setPartitionCount(partitionCount); + b.putAllBrokerLoadBefore(brokerLoadBefore); + b.putAllBrokerLoadAfter(brokerLoadAfter); + return b.build().toByteArray(); + } + + public static RebalanceSummaryEventData fromByteArray(byte[] data) { + try { + RebalanceSummaryEvent e = RebalanceSummaryEvent.parseFrom(data); + return new RebalanceSummaryEventData() + .setRebalanceId(e.getRebalanceId()) + .setTriggerReason(e.getTriggerReason()) + .setPartitionCount(e.getPartitionCount()) + .setBrokerLoadBefore(new HashMap<>(e.getBrokerLoadBeforeMap())) + .setBrokerLoadAfter(new HashMap<>(e.getBrokerLoadAfterMap())); + } catch (Exception ex) { + throw new IllegalArgumentException("Failed to parse RebalanceSummaryEvent", ex); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/RequestErrorEventData.java b/clients/src/main/java/org/apache/kafka/clients/admin/RequestErrorEventData.java new file mode 100644 index 0000000000..63ba309517 --- /dev/null +++ b/clients/src/main/java/org/apache/kafka/clients/admin/RequestErrorEventData.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.kafka.clients.admin; + +import com.automq.events.RequestErrorEvent; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * POJO wrapper for {@link RequestErrorEvent} protobuf message. + *

+ * This class hides all protobuf references so that modules with conflicting + * protobuf versions (shaded vs unshaded) can build and serialize + * {@code RequestErrorEvent} without directly touching protobuf classes. + */ +public class RequestErrorEventData { + + public static final String TYPE = "com.automq.risk.request_error"; + public static final String DATA_SCHEMA = RequestErrorEvent.getDescriptor().getFullName(); + + private int apiKey; + private int errorCode; + private String resource; + private List clientIps = Collections.emptyList(); + private List clientIds = Collections.emptyList(); + private double rps; + private String reason; + + public RequestErrorEventData setApiKey(int apiKey) { + this.apiKey = apiKey; + return this; + } + + public RequestErrorEventData setErrorCode(int errorCode) { + this.errorCode = errorCode; + return this; + } + + public RequestErrorEventData setResource(String resource) { + this.resource = resource; + return this; + } + + public RequestErrorEventData setClientIps(List clientIps) { + this.clientIps = clientIps != null ? clientIps : Collections.emptyList(); + return this; + } + + public RequestErrorEventData setClientIds(List clientIds) { + this.clientIds = clientIds != null ? clientIds : Collections.emptyList(); + return this; + } + + public RequestErrorEventData setRps(double rps) { + this.rps = rps; + return this; + } + + public RequestErrorEventData setReason(String reason) { + this.reason = reason; + return this; + } + + public int apiKey() { + return apiKey; + } + + public int errorCode() { + return errorCode; + } + + public String resource() { + return resource; + } + + public List clientIps() { + return clientIps; + } + + public List clientIds() { + return clientIds; + } + + public double rps() { + return rps; + } + + public String reason() { + return reason; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("RequestErrorEvent{"); + sb.append("apiKey=").append(apiKey); + sb.append(", errorCode=").append(errorCode); + if (resource != null) sb.append(", resource='").append(resource).append('\''); + if (!clientIps.isEmpty()) sb.append(", clientIps=").append(clientIps); + if (!clientIds.isEmpty()) sb.append(", clientIds=").append(clientIds); + sb.append(", rps=").append(rps); + if (reason != null) sb.append(", reason='").append(reason).append('\''); + sb.append('}'); + return sb.toString(); + } + + /** + * Serialize to protobuf byte array. + */ + public byte[] toByteArray() { + RequestErrorEvent.Builder b = RequestErrorEvent.newBuilder() + .setApiKey(apiKey) + .setErrorCode(errorCode) + .addAllClientIps(clientIps) + .addAllClientIds(clientIds) + .setRps(rps); + if (resource != null) { + b.setResource(resource); + } + if (reason != null) { + b.setReason(reason); + } + return b.build().toByteArray(); + } + + /** + * Deserialize from protobuf byte array. + */ + public static RequestErrorEventData fromByteArray(byte[] data) { + try { + RequestErrorEvent event = RequestErrorEvent.parseFrom(data); + return new RequestErrorEventData() + .setApiKey(event.getApiKey()) + .setErrorCode(event.getErrorCode()) + .setResource(event.hasResource() ? event.getResource() : null) + .setClientIps(new ArrayList<>(event.getClientIpsList())) + .setClientIds(new ArrayList<>(event.getClientIdsList())) + .setRps(event.getRps()) + .setReason(event.hasReason() ? event.getReason() : null); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse RequestErrorEvent", e); + } + } +} diff --git a/clients/src/main/java/org/apache/kafka/common/internals/Topic.java b/clients/src/main/java/org/apache/kafka/common/internals/Topic.java index 1069a691b4..88c6e9cfd7 100644 --- a/clients/src/main/java/org/apache/kafka/common/internals/Topic.java +++ b/clients/src/main/java/org/apache/kafka/common/internals/Topic.java @@ -35,6 +35,7 @@ public class Topic { public static final String AUTO_BALANCER_METRICS_TOPIC_NAME = "__auto_balancer_metrics"; public static final String TABLE_TOPIC_CONTROL_TOPIC_NAME = "__automq_table_control"; public static final String TABLE_TOPIC_DATA_TOPIC_NAME = "__automq_table_data"; + public static final String CLUSTER_EVENTS_TOPIC_NAME = "__automq_cluster_events"; // Full name: __automq_consumer_group_force_commit public static final String FORCE_COMMIT_SENTINEL_TOPIC = "__a.c.g.f.c"; // AutoMQ inject end diff --git a/clients/src/main/proto/com/automq/events/cluster_events.proto b/clients/src/main/proto/com/automq/events/cluster_events.proto new file mode 100644 index 0000000000..00333beee7 --- /dev/null +++ b/clients/src/main/proto/com/automq/events/cluster_events.proto @@ -0,0 +1,70 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package com.automq.events; + +option java_package = "com.automq.events"; +option java_multiple_files = true; +option java_outer_classname = "ClusterEventsProto"; + +// Emitted once per AutoBalancer rebalance action — summary of what moved and why. +message RebalanceSummaryEvent { + string rebalance_id = 1; + string trigger_reason = 2; + int32 partition_count = 3; + map broker_load_before = 4; + map broker_load_after = 5; +} + +// Emitted once per partition moved during a rebalance. Correlates with +// RebalanceSummaryEvent via rebalance_id. +message RebalancePartitionEvent { + string rebalance_id = 1; + string topic_partition = 2; + int32 from_broker = 3; + int32 to_broker = 4; + string reason = 5; +} + +// Emitted when a broker failover completes. +message FailoverEvent { + int32 failed_node_id = 1; + int64 detected_timestamp = 2; + int64 completed_timestamp = 3; + string detail = 4; +} + +// Emitted when a broker detects a sustained rate of request errors +// (e.g. auth failures, authorization errors) for a given API/resource. +message RequestErrorEvent { + int32 api_key = 1; + int32 error_code = 2; + optional string resource = 3; + repeated string client_ips = 4; + repeated string client_ids = 5; + double rps = 6; + optional string reason = 7; +} + +// Emitted when a consumer group is committing offsets at an excessive rate. +message OffsetCommitFrequencyEvent { + repeated string client_ips = 1; + repeated string client_ids = 2; + string group_id = 3; + string topic = 4; + double rps = 5; +} diff --git a/clients/src/test/java/org/apache/kafka/clients/admin/ClusterEventTypeRegistryTest.java b/clients/src/test/java/org/apache/kafka/clients/admin/ClusterEventTypeRegistryTest.java new file mode 100644 index 0000000000..d7dd07d20c --- /dev/null +++ b/clients/src/test/java/org/apache/kafka/clients/admin/ClusterEventTypeRegistryTest.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.clients.admin; + +import com.automq.events.FailoverEvent; +import com.automq.events.OffsetCommitFrequencyEvent; +import com.automq.events.RebalancePartitionEvent; +import com.automq.events.RebalanceSummaryEvent; +import com.automq.events.RequestErrorEvent; + +import org.junit.jupiter.api.Test; + +import java.net.URI; +import java.util.Optional; + +import io.cloudevents.CloudEvent; +import io.cloudevents.core.builder.CloudEventBuilder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClusterEventTypeRegistryTest { + + @Test + void decodeRebalanceSummaryEvent() { + RebalanceSummaryEvent payload = RebalanceSummaryEvent.newBuilder() + .setRebalanceId("r1") + .setTriggerReason("load imbalance") + .setPartitionCount(3) + .build(); + + CloudEvent event = buildEvent(RebalanceSummaryEventData.DATA_SCHEMA, payload.toByteArray()); + Optional result = ClusterEventTypeRegistry.decode(event); + + assertTrue(result.isPresent()); + RebalanceSummaryEventData decoded = assertInstanceOf(RebalanceSummaryEventData.class, result.get()); + assertEquals("r1", decoded.rebalanceId()); + assertEquals("load imbalance", decoded.triggerReason()); + assertEquals(3, decoded.partitionCount()); + } + + @Test + void decodeRebalancePartitionEvent() { + RebalancePartitionEvent payload = RebalancePartitionEvent.newBuilder() + .setRebalanceId("r1") + .setTopicPartition("my-topic-0") + .setFromBroker(1) + .setToBroker(2) + .build(); + + CloudEvent event = buildEvent(RebalancePartitionEventData.DATA_SCHEMA, payload.toByteArray()); + Optional result = ClusterEventTypeRegistry.decode(event); + + assertTrue(result.isPresent()); + RebalancePartitionEventData decoded = assertInstanceOf(RebalancePartitionEventData.class, result.get()); + assertEquals("my-topic-0", decoded.topicPartition()); + assertEquals(1, decoded.fromBroker()); + assertEquals(2, decoded.toBroker()); + } + + @Test + void decodeFailoverEvent() { + FailoverEvent payload = FailoverEvent.newBuilder() + .setFailedNodeId(5) + .setDetectedTimestamp(1000L) + .setCompletedTimestamp(2000L) + .setDetail("node unreachable") + .build(); + + CloudEvent event = buildEvent(FailoverEventData.DATA_SCHEMA, payload.toByteArray()); + Optional result = ClusterEventTypeRegistry.decode(event); + + assertTrue(result.isPresent()); + FailoverEventData decoded = assertInstanceOf(FailoverEventData.class, result.get()); + assertEquals(5, decoded.failedNodeId()); + assertEquals("node unreachable", decoded.detail()); + } + + @Test + void decodeRequestErrorEvent() { + RequestErrorEvent payload = RequestErrorEvent.newBuilder() + .setApiKey(0) + .setErrorCode(29) + .setResource("my-topic") + .setRps(42.0) + .build(); + + CloudEvent event = buildEvent(RequestErrorEventData.DATA_SCHEMA, payload.toByteArray()); + Optional result = ClusterEventTypeRegistry.decode(event); + + assertTrue(result.isPresent()); + RequestErrorEventData decoded = assertInstanceOf(RequestErrorEventData.class, result.get()); + assertEquals(0, decoded.apiKey()); + assertEquals(29, decoded.errorCode()); + assertEquals(42.0, decoded.rps(), 0.001); + } + + @Test + void decodeOffsetCommitFrequencyEvent() { + OffsetCommitFrequencyEvent payload = OffsetCommitFrequencyEvent.newBuilder() + .setGroupId("my-group") + .setTopic("my-topic") + .setRps(5000.0) + .build(); + + CloudEvent event = buildEvent(OffsetCommitFrequencyEventData.DATA_SCHEMA, payload.toByteArray()); + Optional result = ClusterEventTypeRegistry.decode(event); + + assertTrue(result.isPresent()); + OffsetCommitFrequencyEventData decoded = assertInstanceOf(OffsetCommitFrequencyEventData.class, result.get()); + assertEquals("my-group", decoded.groupId()); + assertEquals(5000.0, decoded.rps(), 0.001); + } + + @Test + void returnsEmptyForUnknownSchema() { + CloudEvent event = buildEvent("com.example.UnknownEvent", new byte[]{1, 2, 3}); + assertFalse(ClusterEventTypeRegistry.decode(event).isPresent()); + } + + @Test + void returnsEmptyForNullDataSchema() { + CloudEvent event = CloudEventBuilder.v1() + .withId("test-id") + .withType("com.automq.ops.failover") + .withSource(URI.create("/automq/broker/0")) + .withData("application/protobuf", new byte[]{1}) + .build(); + assertFalse(ClusterEventTypeRegistry.decode(event).isPresent()); + } + + @Test + void returnsEmptyForCorruptPayload() { + CloudEvent event = buildEvent(FailoverEventData.DATA_SCHEMA, new byte[]{(byte) 0xFF, (byte) 0xFF}); + assertFalse(ClusterEventTypeRegistry.decode(event).isPresent()); + } + + private static CloudEvent buildEvent(String dataSchema, byte[] payload) { + return CloudEventBuilder.v1() + .withId("test-id") + .withType("com.automq.test") + .withSource(URI.create("/automq/broker/0")) + .withDataSchema(URI.create(dataSchema)) + .withData("application/protobuf", payload) + .build(); + } +} diff --git a/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClient.java b/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClient.java index ea3be5e650..9c180a90ec 100644 --- a/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClient.java +++ b/clients/src/test/java/org/apache/kafka/clients/admin/MockAdminClient.java @@ -1465,6 +1465,11 @@ public ExportClusterManifestResult exportClusterManifest(ExportClusterManifestOp throw new UnsupportedOperationException(); } + @Override + public ClusterEventsReader describeClusterEvents(Long sinceMs) { + throw new UnsupportedOperationException(); + } + // AutoMQ inject end } diff --git a/core/src/main/java/kafka/server/RequestErrorAccumulator.java b/core/src/main/java/kafka/server/RequestErrorAccumulator.java new file mode 100644 index 0000000000..a043b81b9f --- /dev/null +++ b/core/src/main/java/kafka/server/RequestErrorAccumulator.java @@ -0,0 +1,135 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package kafka.server; + +import org.apache.kafka.clients.admin.ClusterEventPublisher; +import org.apache.kafka.clients.admin.RequestErrorEventData; +import org.apache.kafka.common.protocol.ApiKeys; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.LongAdder; + +public class RequestErrorAccumulator implements AutoCloseable { + + private static final Logger log = LoggerFactory.getLogger(RequestErrorAccumulator.class); + + private static final int MAX_CLIENTS = 10; + + record ErrorKey(short apiKey, short errorCode, String resource) { } + + static class ErrorBucket { + final LongAdder count = new LongAdder(); + final Set clientIps = ConcurrentHashMap.newKeySet(); + final Set clientIds = ConcurrentHashMap.newKeySet(); + } + + private final ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + private final int brokerId; + private final long flushIntervalMs; + private final ScheduledExecutorService scheduler; + + public RequestErrorAccumulator(int brokerId, long flushIntervalMs) { + this.brokerId = brokerId; + this.flushIntervalMs = flushIntervalMs; + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "request-error-accumulator-flush"); + t.setDaemon(true); + return t; + }); + this.scheduler.scheduleAtFixedRate(this::flush, + flushIntervalMs, flushIntervalMs, TimeUnit.MILLISECONDS); + } + + public static boolean isRecordable(short errorCode) { + return errorCode != 0; + } + + public void record(short apiKey, short errorCode, String resource, + String clientIp, String clientId) { + ErrorKey key = new ErrorKey(apiKey, errorCode, resource); + ErrorBucket bucket = buckets.computeIfAbsent(key, k -> new ErrorBucket()); + bucket.count.increment(); + if (bucket.clientIps.size() < MAX_CLIENTS) { + bucket.clientIps.add(clientIp); + } + if (bucket.clientIds.size() < MAX_CLIENTS) { + bucket.clientIds.add(clientId); + } + } + + void flush() { + try { + doFlush(); + } catch (Throwable e) { + log.warn("Error flushing request error events", e); + } + } + + private void doFlush() { + double intervalSec = flushIntervalMs / 1000.0; + Iterator> it = buckets.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + // Remove the entry atomically before reading its values so that any new records + // arriving after this point go into a fresh bucket and are not silently dropped. + it.remove(); + ErrorKey key = entry.getKey(); + ErrorBucket bucket = entry.getValue(); + long count = bucket.count.sum(); + if (count == 0) { + continue; + } + + String apiName = ApiKeys.forId(key.apiKey()).name; + + RequestErrorEventData data = new RequestErrorEventData() + .setApiKey(key.apiKey()) + .setErrorCode(key.errorCode()) + .setClientIps(List.copyOf(bucket.clientIps)) + .setClientIds(List.copyOf(bucket.clientIds)) + .setRps(count / intervalSec); + if (!key.resource().isEmpty()) { + data.setResource(key.resource()); + } + + String subject = key.resource().isEmpty() + ? apiName : apiName + ":" + key.resource(); + + ClusterEventPublisher.publish( + RequestErrorEventData.TYPE, + "/automq/broker/" + brokerId, + subject, + RequestErrorEventData.DATA_SCHEMA, + data.toByteArray()); + } + } + + @Override + public void close() { + scheduler.shutdownNow(); + } +} diff --git a/core/src/main/java/kafka/server/ResourceErrorExtractor.java b/core/src/main/java/kafka/server/ResourceErrorExtractor.java new file mode 100644 index 0000000000..ba39e554aa --- /dev/null +++ b/core/src/main/java/kafka/server/ResourceErrorExtractor.java @@ -0,0 +1,408 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package kafka.server; + +import kafka.network.RequestChannel; + +import org.apache.kafka.common.protocol.ApiKeys; +import org.apache.kafka.common.protocol.Errors; +import org.apache.kafka.common.requests.AbstractResponse; +import org.apache.kafka.common.requests.AddOffsetsToTxnRequest; +import org.apache.kafka.common.requests.AddPartitionsToTxnResponse; +import org.apache.kafka.common.requests.AlterConfigsResponse; +import org.apache.kafka.common.requests.ConsumerGroupDescribeRequest; +import org.apache.kafka.common.requests.ConsumerGroupHeartbeatRequest; +import org.apache.kafka.common.requests.CreatePartitionsResponse; +import org.apache.kafka.common.requests.CreateTopicsResponse; +import org.apache.kafka.common.requests.DeleteGroupsResponse; +import org.apache.kafka.common.requests.DeleteTopicsResponse; +import org.apache.kafka.common.requests.DescribeConfigsResponse; +import org.apache.kafka.common.requests.DescribeGroupsResponse; +import org.apache.kafka.common.requests.EndTxnRequest; +import org.apache.kafka.common.requests.FetchResponse; +import org.apache.kafka.common.requests.FindCoordinatorRequest; +import org.apache.kafka.common.requests.FindCoordinatorResponse; +import org.apache.kafka.common.requests.HeartbeatRequest; +import org.apache.kafka.common.requests.IncrementalAlterConfigsResponse; +import org.apache.kafka.common.requests.InitProducerIdRequest; +import org.apache.kafka.common.requests.JoinGroupRequest; +import org.apache.kafka.common.requests.LeaveGroupRequest; +import org.apache.kafka.common.requests.ListOffsetsResponse; +import org.apache.kafka.common.requests.MetadataResponse; +import org.apache.kafka.common.requests.OffsetCommitRequest; +import org.apache.kafka.common.requests.OffsetCommitResponse; +import org.apache.kafka.common.requests.OffsetFetchResponse; +import org.apache.kafka.common.requests.ProduceResponse; +import org.apache.kafka.common.requests.SyncGroupRequest; +import org.apache.kafka.common.requests.TxnOffsetCommitResponse; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Extracts (errorCode, resource) pairs from request/response for recording in RequestErrorAccumulator. + */ +public class ResourceErrorExtractor { + + public record ResourceError(short errorCode, String resource) { } + + /** + * Extract recordable errors with their associated resource from a response. + */ + public static List extract(RequestChannel.Request request, AbstractResponse response) { + try { + return doExtract(request, response); + } catch (Exception e) { + // Never let extraction failures break request processing + return Collections.emptyList(); + } + } + + /** + * Best-effort resource extraction from request only (for closeConnection path). + */ + public static String extractResourceFromRequest(RequestChannel.Request request) { + try { + return doExtractResourceFromRequest(request); + } catch (Exception e) { + return ""; + } + } + + private static List doExtract(RequestChannel.Request request, AbstractResponse response) { + ApiKeys apiKey = request.header().apiKey(); + switch (apiKey) { + case PRODUCE: + return extractProduce((ProduceResponse) response); + case FETCH: + return extractFetch((FetchResponse) response); + case LIST_OFFSETS: + return extractListOffsets((ListOffsetsResponse) response); + case METADATA: + return extractMetadata((MetadataResponse) response); + case OFFSET_COMMIT: + return extractOffsetCommit(request, (OffsetCommitResponse) response); + case OFFSET_FETCH: + return extractOffsetFetch((OffsetFetchResponse) response); + case FIND_COORDINATOR: + return extractFindCoordinator(request, (FindCoordinatorResponse) response); + case CREATE_TOPICS: + return extractCreateTopics((CreateTopicsResponse) response); + case DELETE_TOPICS: + return extractDeleteTopics((DeleteTopicsResponse) response); + case CREATE_PARTITIONS: + return extractCreatePartitions((CreatePartitionsResponse) response); + default: + return doExtractOther(request, response, apiKey); + } + } + + private static List doExtractOther(RequestChannel.Request request, AbstractResponse response, ApiKeys apiKey) { + switch (apiKey) { + case JOIN_GROUP: + return extractSingleErrorFromRequest(response, () -> request.body(JoinGroupRequest.class).data().groupId()); + case SYNC_GROUP: + return extractSingleErrorFromRequest(response, () -> request.body(SyncGroupRequest.class).data().groupId()); + case HEARTBEAT: + return extractSingleErrorFromRequest(response, () -> request.body(HeartbeatRequest.class).data().groupId()); + case LEAVE_GROUP: + return extractSingleErrorFromRequest(response, () -> request.body(LeaveGroupRequest.class).data().groupId()); + case DESCRIBE_GROUPS: + return extractDescribeGroups((DescribeGroupsResponse) response); + case DELETE_GROUPS: + return extractDeleteGroups((DeleteGroupsResponse) response); + case LIST_GROUPS: + return extractSingleError(response, "cluster"); + case INIT_PRODUCER_ID: + return extractSingleErrorFromRequest(response, () -> { + String txnId = request.body(InitProducerIdRequest.class).data().transactionalId(); + return txnId != null ? txnId : ""; + }); + case ADD_PARTITIONS_TO_TXN: + return extractAddPartitionsToTxn((AddPartitionsToTxnResponse) response); + case ADD_OFFSETS_TO_TXN: + return extractSingleErrorFromRequest(response, () -> request.body(AddOffsetsToTxnRequest.class).data().transactionalId()); + case END_TXN: + return extractSingleErrorFromRequest(response, () -> request.body(EndTxnRequest.class).data().transactionalId()); + case TXN_OFFSET_COMMIT: + return extractTxnOffsetCommit((TxnOffsetCommitResponse) response); + default: + return doExtractAdmin(request, response, apiKey); + } + } + + private static List doExtractAdmin(RequestChannel.Request request, AbstractResponse response, ApiKeys apiKey) { + switch (apiKey) { + case ALTER_CONFIGS: + return extractAlterConfigs((AlterConfigsResponse) response); + case DESCRIBE_CONFIGS: + return extractDescribeConfigs((DescribeConfigsResponse) response); + case INCREMENTAL_ALTER_CONFIGS: + return extractIncrementalAlterConfigs((IncrementalAlterConfigsResponse) response); + case CREATE_DELEGATION_TOKEN: + case RENEW_DELEGATION_TOKEN: + case EXPIRE_DELEGATION_TOKEN: + return extractSingleError(response, "delegation-token"); + case SASL_HANDSHAKE: + case SASL_AUTHENTICATE: + return extractSingleError(response, "sasl"); + case CREATE_ACLS: + case DELETE_ACLS: + case DESCRIBE_ACLS: + return extractSingleError(response, "cluster"); + case CONSUMER_GROUP_HEARTBEAT: + return extractSingleErrorFromRequest(response, () -> request.body(ConsumerGroupHeartbeatRequest.class).data().groupId()); + case CONSUMER_GROUP_DESCRIBE: + return extractSingleErrorFromRequest(response, () -> { + List ids = request.body(ConsumerGroupDescribeRequest.class).data().groupIds(); + return ids.isEmpty() ? "" : ids.get(0); + }); + default: + return extractDefaultErrors(response); + } + } + + // ---- Per-topic response extractors ---- + + private static List extractProduce(ProduceResponse response) { + List result = new ArrayList<>(); + response.data().responses().forEach(topic -> + topic.partitionResponses().forEach(p -> + maybeAdd(result, p.errorCode(), topic.name()))); + return result; + } + + private static List extractFetch(FetchResponse response) { + List result = new ArrayList<>(); + // top-level error + maybeAdd(result, response.error().code(), ""); + response.data().responses().forEach(topic -> + topic.partitions().forEach(p -> + maybeAdd(result, p.errorCode(), topic.topic()))); + return result; + } + + private static List extractListOffsets(ListOffsetsResponse response) { + List result = new ArrayList<>(); + response.data().topics().forEach(topic -> + topic.partitions().forEach(p -> + maybeAdd(result, p.errorCode(), topic.name()))); + return result; + } + + private static List extractMetadata(MetadataResponse response) { + List result = new ArrayList<>(); + response.data().topics().forEach(topic -> + maybeAdd(result, topic.errorCode(), topic.name() != null ? topic.name() : "")); + return result; + } + + private static List extractOffsetCommit(RequestChannel.Request request, OffsetCommitResponse response) { + List result = new ArrayList<>(); + String groupId = request.body(OffsetCommitRequest.class).data().groupId(); + response.data().topics().forEach(topic -> + topic.partitions().forEach(p -> { + short code = p.errorCode(); + if (code == Errors.GROUP_AUTHORIZATION_FAILED.code()) { + maybeAdd(result, code, groupId); + } else { + maybeAdd(result, code, topic.name()); + } + })); + return result; + } + + private static List extractOffsetFetch(OffsetFetchResponse response) { + List result = new ArrayList<>(); + // v8+ batched groups + response.data().groups().forEach(group -> + maybeAdd(result, group.errorCode(), group.groupId())); + // older single-group + if (response.data().groups().isEmpty()) { + maybeAdd(result, response.data().errorCode(), ""); + } + return result; + } + + private static List extractFindCoordinator(RequestChannel.Request request, FindCoordinatorResponse response) { + List result = new ArrayList<>(); + if (!response.data().coordinators().isEmpty()) { + response.data().coordinators().forEach(c -> + maybeAdd(result, c.errorCode(), c.key())); + } else { + String key = request.body(FindCoordinatorRequest.class).data().key(); + maybeAdd(result, response.data().errorCode(), key != null ? key : ""); + } + return result; + } + + private static List extractCreateTopics(CreateTopicsResponse response) { + List result = new ArrayList<>(); + response.data().topics().forEach(topic -> + maybeAdd(result, topic.errorCode(), topic.name())); + return result; + } + + private static List extractDeleteTopics(DeleteTopicsResponse response) { + List result = new ArrayList<>(); + response.data().responses().forEach(topic -> + maybeAdd(result, topic.errorCode(), topic.name() != null ? topic.name() : "")); + return result; + } + + private static List extractCreatePartitions(CreatePartitionsResponse response) { + List result = new ArrayList<>(); + response.data().results().forEach(r -> + maybeAdd(result, r.errorCode(), r.name())); + return result; + } + + // ---- Per-group response extractors ---- + + private static List extractDescribeGroups(DescribeGroupsResponse response) { + List result = new ArrayList<>(); + response.data().groups().forEach(g -> + maybeAdd(result, g.errorCode(), g.groupId())); + return result; + } + + private static List extractDeleteGroups(DeleteGroupsResponse response) { + List result = new ArrayList<>(); + response.data().results().forEach(r -> + maybeAdd(result, r.errorCode(), r.groupId())); + return result; + } + + // ---- Transaction extractors ---- + + private static List extractAddPartitionsToTxn(AddPartitionsToTxnResponse response) { + List result = new ArrayList<>(); + // v3 and below: per-topic results + response.data().resultsByTopicV3AndBelow().forEach(topic -> + topic.resultsByPartition().forEach(p -> + maybeAdd(result, p.partitionErrorCode(), topic.name()))); + // v4+: per-transaction, per-topic results + response.data().resultsByTransaction().forEach(txn -> + txn.topicResults().forEach(topic -> + topic.resultsByPartition().forEach(p -> + maybeAdd(result, p.partitionErrorCode(), topic.name())))); + return result; + } + + private static List extractTxnOffsetCommit(TxnOffsetCommitResponse response) { + List result = new ArrayList<>(); + response.data().topics().forEach(topic -> + topic.partitions().forEach(p -> + maybeAdd(result, p.errorCode(), topic.name()))); + return result; + } + + // ---- Config extractors ---- + + private static List extractAlterConfigs(AlterConfigsResponse response) { + List result = new ArrayList<>(); + response.data().responses().forEach(r -> + maybeAdd(result, r.errorCode(), r.resourceName())); + return result; + } + + private static List extractDescribeConfigs(DescribeConfigsResponse response) { + List result = new ArrayList<>(); + response.data().results().forEach(r -> + maybeAdd(result, r.errorCode(), r.resourceName())); + return result; + } + + private static List extractIncrementalAlterConfigs(IncrementalAlterConfigsResponse response) { + List result = new ArrayList<>(); + response.data().responses().forEach(r -> + maybeAdd(result, r.errorCode(), r.resourceName())); + return result; + } + + // ---- Helpers ---- + + private static List extractSingleError(AbstractResponse response, String resource) { + List result = new ArrayList<>(); + response.errorCounts().forEach((error, count) -> + maybeAdd(result, error.code(), resource)); + return result; + } + + @FunctionalInterface + private interface ResourceSupplier { + String get(); + } + + private static List extractSingleErrorFromRequest( + AbstractResponse response, ResourceSupplier resourceSupplier) { + List result = new ArrayList<>(); + String resource = resourceSupplier.get(); + response.errorCounts().forEach((error, count) -> + maybeAdd(result, error.code(), resource)); + return result; + } + + private static List extractDefaultErrors(AbstractResponse response) { + List result = new ArrayList<>(); + response.errorCounts().forEach((error, count) -> + maybeAdd(result, error.code(), "")); + return result; + } + + private static void maybeAdd(List result, short errorCode, String resource) { + if (errorCode != 0) { + result.add(new ResourceError(errorCode, resource)); + } + } + + private static String doExtractResourceFromRequest(RequestChannel.Request request) { + ApiKeys apiKey = request.header().apiKey(); + switch (apiKey) { + case PRODUCE: + var produceTopics = request.body(org.apache.kafka.common.requests.ProduceRequest.class).data().topicData(); + return produceTopics.isEmpty() ? "" : produceTopics.iterator().next().name(); + case JOIN_GROUP: + return request.body(JoinGroupRequest.class).data().groupId(); + case SYNC_GROUP: + return request.body(SyncGroupRequest.class).data().groupId(); + case HEARTBEAT: + return request.body(HeartbeatRequest.class).data().groupId(); + case LEAVE_GROUP: + return request.body(LeaveGroupRequest.class).data().groupId(); + case OFFSET_COMMIT: + return request.body(OffsetCommitRequest.class).data().groupId(); + case INIT_PRODUCER_ID: { + String txnId = request.body(InitProducerIdRequest.class).data().transactionalId(); + return txnId != null ? txnId : ""; + } + case ADD_OFFSETS_TO_TXN: + return request.body(AddOffsetsToTxnRequest.class).data().transactionalId(); + case END_TXN: + return request.body(EndTxnRequest.class).data().transactionalId(); + case FIND_COORDINATOR: { + String key = request.body(FindCoordinatorRequest.class).data().key(); + return key != null ? key : ""; + } + case CONSUMER_GROUP_HEARTBEAT: + return request.body(ConsumerGroupHeartbeatRequest.class).data().groupId(); + default: + return ""; + } + } +} diff --git a/core/src/main/scala/kafka/network/RequestChannel.scala b/core/src/main/scala/kafka/network/RequestChannel.scala index ac856f5e27..a9ce32e09e 100644 --- a/core/src/main/scala/kafka/network/RequestChannel.scala +++ b/core/src/main/scala/kafka/network/RequestChannel.scala @@ -21,7 +21,7 @@ import com.fasterxml.jackson.databind.JsonNode import com.typesafe.scalalogging.Logger import com.yammer.metrics.core.{Histogram, Meter} import kafka.network -import kafka.server.{KafkaConfig, RequestLocal} +import kafka.server.{KafkaConfig, RequestErrorAccumulator, RequestLocal, ResourceErrorExtractor} import kafka.utils.Implicits._ import kafka.utils.{Logging, Pool} import org.apache.kafka.common.config.ConfigResource @@ -188,6 +188,12 @@ object RequestChannel extends Logging { } } + // AutoMQ inject start + def body[T <: AbstractRequest](clazz: Class[T]): T = { + clazz.cast(bodyAndSize.request) + } + // AutoMQ inject end + def loggableRequest: AbstractRequest = { bodyAndSize.request match { @@ -392,6 +398,10 @@ class RequestChannel(val queueSize: Int, private val multiCallbackQueue = new java.util.ArrayList[ArrayBlockingQueue[BaseRequest]]() private var notifiedShutdown = false + // AutoMQ inject start - request error accumulator + @volatile var requestErrorAccumulator: RequestErrorAccumulator = _ + // AutoMQ inject end + metricsGroup.newGauge(requestQueueSizeMetricName, () => { if (multiRequestQueue.size() != 0) { multiRequestQueue.stream().mapToInt(q => q.size()).sum() @@ -456,6 +466,9 @@ class RequestChannel(val queueSize: Int, // This case is used when the request handler has encountered an error, but the client // does not expect a response (e.g. when produce request has acks set to 0) updateErrorMetrics(request.header.apiKey, errorCounts.asScala) + // AutoMQ inject start + maybeRecordRequestErrorsFromMap(request, errorCounts) + // AutoMQ inject end sendResponse(new RequestChannel.CloseConnectionResponse(request)) } @@ -465,6 +478,9 @@ class RequestChannel(val queueSize: Int, onComplete: Option[Send => Unit] ): Unit = { updateErrorMetrics(request.header.apiKey, response.errorCounts.asScala) + // AutoMQ inject start + maybeRecordRequestErrors(request, response) + // AutoMQ inject end sendResponse(new RequestChannel.SendResponse( request, request.buildResponseSend(response), @@ -568,6 +584,40 @@ class RequestChannel(val queueSize: Int, def receiveRequest(): RequestChannel.BaseRequest = requestQueue.take() + // AutoMQ inject start + private def maybeRecordRequestErrors( + request: RequestChannel.Request, + response: AbstractResponse + ): Unit = { + val acc = requestErrorAccumulator + if (acc == null) return + val errors = ResourceErrorExtractor.extract(request, response) + if (errors.isEmpty) return + val clientIp = request.context.clientAddress.getHostAddress + val clientId = request.header.clientId + val apiKey = request.header.apiKey.id + errors.forEach { re => + acc.record(apiKey, re.errorCode, re.resource, clientIp, clientId) + } + } + + private def maybeRecordRequestErrorsFromMap( + request: RequestChannel.Request, + errorCounts: java.util.Map[Errors, Integer] + ): Unit = { + val acc = requestErrorAccumulator + if (acc == null) return + val clientIp = request.context.clientAddress.getHostAddress + val clientId = request.header.clientId + val apiKey = request.header.apiKey.id + val resource = ResourceErrorExtractor.extractResourceFromRequest(request) + errorCounts.forEach { (error, _) => + if (RequestErrorAccumulator.isRecordable(error.code)) + acc.record(apiKey, error.code, resource, clientIp, clientId) + } + } + // AutoMQ inject end + def updateErrorMetrics(apiKey: ApiKeys, errors: collection.Map[Errors, Integer]): Unit = { errors.forKeyValue { (error, count) => metrics(apiKey.name).markErrorMeter(error, count) diff --git a/core/src/main/scala/kafka/server/AutoTopicCreationManager.scala b/core/src/main/scala/kafka/server/AutoTopicCreationManager.scala index 9060c464bf..c217d342c6 100644 --- a/core/src/main/scala/kafka/server/AutoTopicCreationManager.scala +++ b/core/src/main/scala/kafka/server/AutoTopicCreationManager.scala @@ -24,10 +24,11 @@ import kafka.controller.KafkaController import kafka.coordinator.transaction.TransactionCoordinator import kafka.utils.Logging import org.apache.kafka.clients.ClientResponse +import org.apache.kafka.clients.admin.ClusterEventsConfig import org.apache.kafka.common.config.TopicConfig import org.apache.kafka.common.errors.InvalidTopicException import org.apache.kafka.common.internals.Topic -import org.apache.kafka.common.internals.Topic.{GROUP_METADATA_TOPIC_NAME, TABLE_TOPIC_CONTROL_TOPIC_NAME, TABLE_TOPIC_DATA_TOPIC_NAME, TRANSACTION_STATE_TOPIC_NAME} +import org.apache.kafka.common.internals.Topic.{CLUSTER_EVENTS_TOPIC_NAME, GROUP_METADATA_TOPIC_NAME, TABLE_TOPIC_CONTROL_TOPIC_NAME, TABLE_TOPIC_DATA_TOPIC_NAME, TRANSACTION_STATE_TOPIC_NAME} import org.apache.kafka.common.message.CreateTopicsRequestData import org.apache.kafka.common.message.CreateTopicsRequestData.{CreatableTopic, CreatableTopicConfig, CreatableTopicConfigCollection} import org.apache.kafka.common.message.MetadataResponseData.MetadataResponseTopic @@ -265,6 +266,15 @@ class DefaultAutoTopicCreationManager( .setReplicationFactor(1) .setConfigs(convertToTopicConfigCollections(configs)) } + case CLUSTER_EVENTS_TOPIC_NAME => { + val configs = new Properties() + configs.put(TopicConfig.RETENTION_MS_CONFIG, ClusterEventsConfig.DEFAULT_RETENTION_MS) + new CreatableTopic() + .setName(topic) + .setNumPartitions(ClusterEventsConfig.DEFAULT_PARTITIONS) + .setReplicationFactor(1) + .setConfigs(convertToTopicConfigCollections(configs)) + } // AutoMQ inject end case topicName => diff --git a/core/src/main/scala/kafka/server/BrokerServer.scala b/core/src/main/scala/kafka/server/BrokerServer.scala index 28ad477b4b..38fda51d98 100644 --- a/core/src/main/scala/kafka/server/BrokerServer.scala +++ b/core/src/main/scala/kafka/server/BrokerServer.scala @@ -174,6 +174,7 @@ class BrokerServer( config.addReconfigurable(clientRackProvider) var tableManager: TableManager = _ + var requestErrorAccumulator: RequestErrorAccumulator = _ // AutoMQ inject end private def maybeChangeStatus(from: ProcessStatus, to: ProcessStatus): Boolean = { @@ -462,6 +463,22 @@ class BrokerServer( config.numIoThreads, s"${DataPlaneAcceptor.MetricPrefix}RequestHandlerAvgIdlePercent", DataPlaneAcceptor.ThreadPrefix) + // AutoMQ inject start - cluster event publisher and request error accumulator + try { + val publisherConfig = new java.util.HashMap[String, Object]() + kafka.automq.utils.ClientUtils.clusterClientBaseConfig(config).forEach { (k, v) => + publisherConfig.put(k.toString, v) + } + config.rack.foreach(rack => publisherConfig.put("client.id", "automq_az=" + rack)) + org.apache.kafka.clients.admin.ClusterEventPublisher.setup(publisherConfig) + requestErrorAccumulator = new RequestErrorAccumulator(config.nodeId, 30000L) + socketServer.dataPlaneRequestChannel.requestErrorAccumulator = requestErrorAccumulator + } catch { + case e: Throwable => + error("Failed to initialize ClusterEventPublisher/RequestErrorAccumulator", e) + } + // AutoMQ inject end + // Start RemoteLogManager before initializing broker metadata publishers. remoteLogManagerOpt.foreach { rlm => val listenerName = config.remoteLogManagerConfig.remoteLogMetadataManagerListenerName() @@ -797,6 +814,12 @@ class BrokerServer( if (quotaManagers != null) CoreUtils.swallow(quotaManagers.shutdown(), this) + // AutoMQ inject start - shutdown request error accumulator and cluster event publisher + if (requestErrorAccumulator != null) + CoreUtils.swallow(requestErrorAccumulator.close(), this) + CoreUtils.swallow(org.apache.kafka.clients.admin.ClusterEventPublisher.shutdown(), this) + // AutoMQ inject end + if (socketServer != null) CoreUtils.swallow(socketServer.shutdown(), this) if (brokerTopicStats != null) diff --git a/core/src/main/scala/kafka/server/KafkaApis.scala b/core/src/main/scala/kafka/server/KafkaApis.scala index 5ac924ab36..6967f95649 100644 --- a/core/src/main/scala/kafka/server/KafkaApis.scala +++ b/core/src/main/scala/kafka/server/KafkaApis.scala @@ -1321,10 +1321,10 @@ class KafkaApis(val requestChannel: RequestChannel, autoTopicCreationManager.createTopics(nonExistingTopics, controllerMutationQuota, Some(request.context)) } else { // AutoMQ inject start - for (tableTopic <- Set(Topic.TABLE_TOPIC_CONTROL_TOPIC_NAME, Topic.TABLE_TOPIC_DATA_TOPIC_NAME)) { - if (nonExistingTopics.contains(tableTopic)) { + for (autoCreateTopic <- Set(Topic.TABLE_TOPIC_CONTROL_TOPIC_NAME, Topic.TABLE_TOPIC_DATA_TOPIC_NAME, Topic.CLUSTER_EVENTS_TOPIC_NAME)) { + if (nonExistingTopics.contains(autoCreateTopic)) { val controllerMutationQuota = quotas.controllerMutation.newPermissiveQuotaFor(request) - autoTopicCreationManager.createTopics(Set(tableTopic), controllerMutationQuota, Some(request.context)) + autoTopicCreationManager.createTopics(Set(autoCreateTopic), controllerMutationQuota, Some(request.context)) } } // AutoMQ inject end diff --git a/core/src/test/java/kafka/server/RequestErrorEventDataTest.java b/core/src/test/java/kafka/server/RequestErrorEventDataTest.java new file mode 100644 index 0000000000..839e431af7 --- /dev/null +++ b/core/src/test/java/kafka/server/RequestErrorEventDataTest.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package kafka.server; + +import org.apache.kafka.clients.admin.RequestErrorEventData; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class RequestErrorEventDataTest { + + @Test + void roundTripSerialization() { + RequestErrorEventData original = new RequestErrorEventData() + .setApiKey(0) + .setErrorCode(29) + .setResource("my-topic") + .setClientIps(List.of("10.0.0.1")) + .setClientIds(List.of("producer-1")) + .setRps(42.0); + + byte[] bytes = original.toByteArray(); + assertNotNull(bytes); + + RequestErrorEventData decoded = RequestErrorEventData.fromByteArray(bytes); + assertEquals(0, decoded.apiKey()); + assertEquals(29, decoded.errorCode()); + assertEquals("my-topic", decoded.resource()); + assertEquals(List.of("10.0.0.1"), decoded.clientIps()); + assertEquals(List.of("producer-1"), decoded.clientIds()); + assertEquals(42.0, decoded.rps(), 0.001); + } +} diff --git a/devkit/Dockerfile b/devkit/Dockerfile index 185913a01c..33fd495f46 100644 --- a/devkit/Dockerfile +++ b/devkit/Dockerfile @@ -2,11 +2,13 @@ ARG jdk_version=eclipse-temurin:17-jdk-jammy FROM $jdk_version ENV DEBIAN_FRONTEND=noninteractive +ENV PATH="/root/.local/bin:$PATH" RUN apt-get update && apt-get install -y \ - curl jq iproute2 iptables iputils-ping net-tools wget \ + curl jq iproute2 iptables iputils-ping net-tools wget python3 \ && wget -O /usr/local/bin/jmxterm.jar https://github.com/jiaqi/jmxterm/releases/download/v1.0.4/jmxterm-1.0.4-uber.jar \ && mkdir -p /opt/arthas && wget -O /opt/arthas/arthas-boot.jar https://arthas.aliyun.com/arthas-boot.jar \ + && curl -LsSf https://astral.sh/uv/install.sh | sh \ && apt-get -y clean && rm -rf /var/lib/apt/lists/* WORKDIR /opt/automq diff --git a/devkit/README.md b/devkit/README.md index 1ef8fb6f5e..20e6d3d587 100644 --- a/devkit/README.md +++ b/devkit/README.md @@ -313,6 +313,29 @@ just start 5 zerozone analytics # 5-node + zone router + query engines | `telemetry` | OTel metrics export via OTLP HTTP — **edit `config/features/telemetry.properties` to set collector endpoint before use** | — (config only) | | `analytics` | Spark + Trino for querying Iceberg tables | Spark (:8888), Trino (:8090) | +## Namespace (Multi-Instance Isolation) + +Run multiple DevKit instances on the same machine without port conflicts. Each namespace gets a unique `COMPOSE_PROJECT_NAME` and port offset. + +**Requires separate git worktrees** — each worktree has its own `devkit/` directory and `.devkit/compose.env`, so each instance is fully isolated. You cannot run multiple namespaces from the same devkit directory. + +```bash +# In worktree A: +just set-ns lag-test 1 # ID=1 → ports offset by +100 +just start 3 + +# In worktree B: +just set-ns perf 2 # ID=2 → ports offset by +200 +just start + +# Revert to default ports +just clear-ns +``` + +- Name: 1-8 chars, must start with a letter, only `[a-z0-9-]` +- ID: 1-9, determines port offset (`ID * 100`) +- Config is stored in `.devkit/compose.env` (gitignored) + ## Port Allocation | Node | Kafka | Controller | JDWP | JMX | @@ -353,6 +376,8 @@ Other services: | `S3_DATA_BUCKET` | `automq-data` | Data bucket | | `S3_OPS_BUCKET` | `automq-ops` | Ops bucket | | `S3_PATH_STYLE` | `true` | Path-style access (required for MinIO) | +| `S3_ACCESS_KEY` | `admin` | S3 access key (exported as `KAFKA_S3_ACCESS_KEY` to containers) | +| `S3_SECRET_KEY` | `password` | S3 secret key (exported as `KAFKA_S3_SECRET_KEY` to containers) | **Per-node variables** (computed per node, Layer 2/3/4): @@ -373,6 +398,8 @@ S3_ENDPOINT := "https://s3.ap-northeast-1.amazonaws.com" S3_DATA_BUCKET := "my-automq-data" S3_OPS_BUCKET := "my-automq-ops" S3_PATH_STYLE := "false" +S3_ACCESS_KEY := "AKIA..." +S3_SECRET_KEY := "..." ``` **Change listeners (e.g. SASL)** — edit `config/role/server.properties`: diff --git a/devkit/docker-compose.yml b/devkit/docker-compose.yml index 74e18699cc..a5ea6bf629 100644 --- a/devkit/docker-compose.yml +++ b/devkit/docker-compose.yml @@ -12,10 +12,11 @@ x-automq-common: &automq-common labels: com.automq.devkit: "true" environment: - - KAFKA_S3_ACCESS_KEY=admin - - KAFKA_S3_SECRET_KEY=password + - KAFKA_S3_ACCESS_KEY=${KAFKA_S3_ACCESS_KEY} + - KAFKA_S3_SECRET_KEY=${KAFKA_S3_SECRET_KEY} - KAFKA_HEAP_OPTS=${KAFKA_HEAP_OPTS:--Xms1g -Xmx4g -XX:MetaspaceSize=96m -XX:MaxDirectMemorySize=1G} - KAFKA_JVM_PERFORMANCE_OPTS=-server -XX:+UseZGC -XX:ZCollectionInterval=5 + - LOG_DIR=/tmp/kafka-logs depends_on: mc: condition: service_completed_successfully @@ -35,15 +36,14 @@ services: # ==================== Infrastructure ==================== minio: image: minio/minio:RELEASE.2025-05-24T17-08-30Z - container_name: minio hostname: warehouse.minio environment: - MINIO_ROOT_USER=admin - MINIO_ROOT_PASSWORD=password - MINIO_DOMAIN=minio ports: - - "9000:9000" - - "9001:9001" + - "${MINIO_API_PORT:-9000}:9000" + - "${MINIO_CONSOLE_PORT:-9001}:9001" command: ["server", "/data", "--console-address", ":9001"] healthcheck: test: ["CMD", "curl", "-f", "http://minio:9000/minio/health/live"] @@ -53,7 +53,6 @@ services: mc: image: minio/mc:RELEASE.2025-05-21T01-59-54Z - container_name: mc depends_on: minio: condition: service_healthy @@ -69,13 +68,12 @@ services: # ==================== AutoMQ Nodes ==================== node-0: <<: *automq-common - container_name: node-0 hostname: node-0 profiles: [single, cluster, cluster4, cluster5] ports: - - "9092:9192" - - "5005:5005" - - "9999:9999" + - "${NODE0_KAFKA_PORT:-9092}:9192" + - "${NODE0_DEBUG_PORT:-5005}:5005" + - "${NODE0_JMX_PORT:-9999}:9999" command: - bash - -c @@ -88,12 +86,11 @@ services: node-1: <<: *automq-common - container_name: node-1 hostname: node-1 profiles: [cluster, cluster4, cluster5] ports: - - "19092:9192" - - "5006:5005" + - "${NODE1_KAFKA_PORT:-19092}:9192" + - "${NODE1_DEBUG_PORT:-5006}:5005" command: - bash - -c @@ -106,12 +103,11 @@ services: node-2: <<: *automq-common - container_name: node-2 hostname: node-2 profiles: [cluster, cluster4, cluster5] ports: - - "29092:9192" - - "5007:5005" + - "${NODE2_KAFKA_PORT:-29092}:9192" + - "${NODE2_DEBUG_PORT:-5007}:5005" command: - bash - -c @@ -124,12 +120,11 @@ services: node-3: <<: *automq-common - container_name: node-3 hostname: node-3 profiles: [cluster4, cluster5] ports: - - "39092:9192" - - "5008:5005" + - "${NODE3_KAFKA_PORT:-39092}:9192" + - "${NODE3_DEBUG_PORT:-5008}:5005" command: - bash - -c @@ -142,12 +137,11 @@ services: node-4: <<: *automq-common - container_name: node-4 hostname: node-4 profiles: [cluster5] ports: - - "49092:9192" - - "5009:5005" + - "${NODE4_KAFKA_PORT:-49092}:9192" + - "${NODE4_DEBUG_PORT:-5009}:5005" command: - bash - -c @@ -161,11 +155,10 @@ services: # ==================== TableTopic ==================== schema-registry: image: confluentinc/cp-schema-registry:7.7.8 - container_name: schema-registry hostname: schema-registry profiles: [tabletopic] ports: - - "8081:8081" + - "${SCHEMA_REGISTRY_PORT:-8081}:8081" environment: SCHEMA_REGISTRY_HOST_NAME: schema-registry SCHEMA_REGISTRY_KAFKASTORE_BOOTSTRAP_SERVERS: node-0:9092 @@ -179,11 +172,10 @@ services: rest: image: apache/iceberg-rest-fixture - container_name: iceberg-rest hostname: rest profiles: [tabletopic] ports: - - "8181:8181" + - "${ICEBERG_REST_PORT:-8181}:8181" depends_on: minio: condition: service_healthy @@ -204,7 +196,6 @@ services: # ==================== Analytics ==================== spark-iceberg: image: tabulario/spark-iceberg:3.5.5_1.8.1 - container_name: spark hostname: spark-iceberg profiles: [analytics] depends_on: @@ -215,18 +206,17 @@ services: - AWS_SECRET_ACCESS_KEY=password - AWS_REGION=us-east-1 ports: - - "8888:8888" - - "8080:8080" + - "${SPARK_NOTEBOOK_PORT:-8888}:8888" + - "${SPARK_UI_PORT:-8080}:8080" trino: image: trinodb/trino:472 - container_name: trino hostname: trino profiles: [analytics] depends_on: minio: condition: service_healthy ports: - - "8090:8080" + - "${TRINO_PORT:-8090}:8080" volumes: - ./config/trino-catalog:/etc/trino/catalog diff --git a/devkit/justfile b/devkit/justfile index 1b3604a1b5..dcf817a06d 100644 --- a/devkit/justfile +++ b/devkit/justfile @@ -3,20 +3,25 @@ set dotenv-load := false export AUTOMQ_DEV_HOME := justfile_directory() + "/.." +export KAFKA_S3_ACCESS_KEY := S3_ACCESS_KEY +export KAFKA_S3_SECRET_KEY := S3_SECRET_KEY DEVKIT := justfile_directory() + "/.devkit" -COMPOSE := "docker compose -f " + justfile_directory() + "/docker-compose.yml" +_ENV_FILE_FLAG := if path_exists(justfile_directory() + "/.devkit/compose.env") == "true" { "--env-file " + justfile_directory() + "/.devkit/compose.env" } else { "" } +COMPOSE := "docker compose -f " + justfile_directory() + "/docker-compose.yml " + _ENV_FILE_FLAG ALL_PROFILES := "--profile single --profile cluster --profile cluster4 --profile cluster5 --profile tabletopic --profile analytics" AZ_NAMES := "az-0 az-1 az-2" HEAP_OPTS := "-Xms256m -Xmx256m" KAFKA_OPTS := "KAFKA_HEAP_OPTS='" + HEAP_OPTS + "' KAFKA_JVM_PERFORMANCE_OPTS='' JMX_PORT=''" # S3 configuration -S3_REGION := "us-east-1" -S3_ENDPOINT := "http://minio:9000" +S3_REGION := "us-east-1" +S3_ENDPOINT := "http://minio:9000" S3_DATA_BUCKET := "automq-data" S3_OPS_BUCKET := "automq-ops" S3_PATH_STYLE := "true" +S3_ACCESS_KEY := "admin" +S3_SECRET_KEY := "password" default: @just --list @@ -81,7 +86,8 @@ _render-node node ctrl_count cluster_id voters bootstrap features="": export VOTERS="{{voters}}" export BOOTSTRAP="{{bootstrap}}" export NODE_ID="{{node}}" - export HOST_PORT=$(({{node}} * 10000 + 9092)) + PORT_OFFSET=$(grep -s '^PORT_OFFSET=' "{{DEVKIT}}/compose.env" | cut -d= -f2) + export HOST_PORT=$(({{node}} * 10000 + 9092 + ${PORT_OFFSET:-0})) export AZ_NAME="${AZS[$(({{node}} % ${#AZS[@]}))]}" render() { envsubst < "$1" | grep -v '^#' | grep -v '^$'; } @@ -123,16 +129,83 @@ generate-config nodes features="": [private] _node-list: - @docker ps --filter "label=com.automq.devkit=true" --format '{{ '{{' }}.Names{{ '}}' }}' + @{{COMPOSE}} ps --format '{{ '{{' }}.Service{{ '}}' }}' 2>/dev/null | grep 'node-' || true [private] _ensure-iptables-chain node: #!/usr/bin/env bash - docker exec {{node}} iptables -N DEVKIT 2>/dev/null || true - docker exec {{node}} iptables -C OUTPUT -j DEVKIT 2>/dev/null || \ - docker exec {{node}} iptables -A OUTPUT -j DEVKIT - docker exec {{node}} iptables -C INPUT -j DEVKIT 2>/dev/null || \ - docker exec {{node}} iptables -A INPUT -j DEVKIT + {{COMPOSE}} exec {{node}} iptables -N DEVKIT 2>/dev/null || true + {{COMPOSE}} exec {{node}} iptables -C OUTPUT -j DEVKIT 2>/dev/null || \ + {{COMPOSE}} exec {{node}} iptables -A OUTPUT -j DEVKIT + {{COMPOSE}} exec {{node}} iptables -C INPUT -j DEVKIT 2>/dev/null || \ + {{COMPOSE}} exec {{node}} iptables -A INPUT -j DEVKIT + +# ==================== Namespace ==================== + +[doc("Set namespace for multi-instance isolation. Example: just set-ns lag-test 1")] +set-ns name id: + #!/usr/bin/env bash + set -e + NAME="{{name}}" + ID="{{id}}" + if ! [[ "$NAME" =~ ^[a-z][a-z0-9-]{0,7}$ ]]; then + echo "✗ Name must be 1-8 chars, start with a letter, only [a-z0-9-]" + exit 1 + fi + if ! [[ "$ID" =~ ^[1-9]$ ]]; then + echo "✗ ID must be 1-9" + exit 1 + fi + + OFFSET=$((ID * 100)) + CHECK_PORT=$((9092 + OFFSET)) + + if lsof -i ":${CHECK_PORT}" >/dev/null 2>&1; then + echo "✗ Port ${CHECK_PORT} is already in use. Try a different ID." + exit 1 + fi + + # Base ports — each gets +OFFSET in the generated env file + declare -a PORT_DEFS=( + "NODE0_KAFKA_PORT=9092" + "NODE0_DEBUG_PORT=5005" + "NODE0_JMX_PORT=9999" + "NODE1_KAFKA_PORT=19092" + "NODE1_DEBUG_PORT=5006" + "NODE2_KAFKA_PORT=29092" + "NODE2_DEBUG_PORT=5007" + "NODE3_KAFKA_PORT=39092" + "NODE3_DEBUG_PORT=5008" + "NODE4_KAFKA_PORT=49092" + "NODE4_DEBUG_PORT=5009" + "MINIO_API_PORT=9000" + "MINIO_CONSOLE_PORT=9001" + "SCHEMA_REGISTRY_PORT=8081" + "ICEBERG_REST_PORT=8181" + "SPARK_NOTEBOOK_PORT=8888" + "SPARK_UI_PORT=8080" + "TRINO_PORT=8090" + ) + + mkdir -p "{{DEVKIT}}" + { + echo "COMPOSE_PROJECT_NAME=devkit-$NAME" + echo "PORT_OFFSET=$OFFSET" + for def in "${PORT_DEFS[@]}"; do + key="${def%%=*}" + base="${def#*=}" + echo "${key}=$((base + OFFSET))" + done + } > "{{DEVKIT}}/compose.env" + + echo "✓ Namespace: ${NAME} (id=${ID})" + echo " Port offset: +${OFFSET}" + echo " Kafka will be on port ${CHECK_PORT}" + +[doc("Clear namespace, revert to default ports")] +clear-ns: + rm -f "{{DEVKIT}}/compose.env" + @echo "✓ Namespace cleared — using default ports" # ==================== Lifecycle ==================== @@ -152,10 +225,12 @@ start nodes="1" *FEATURES: ensure-cluster-id echo "✓ Starting {{nodes}} node(s)..." {{COMPOSE}} $COMPOSE_PROFILES up -d echo "" + PORT_OFFSET=$(grep -s '^PORT_OFFSET=' "{{DEVKIT}}/compose.env" | cut -d= -f2) + PORT_OFFSET=${PORT_OFFSET:-0} echo "🎉 AutoMQ DevKit started" - echo " Kafka: localhost:9092" - echo " Debug: localhost:5005" - echo " MinIO: http://localhost:9001 (admin/password)" + echo " Kafka: localhost:$((9092 + PORT_OFFSET))" + echo " Debug: localhost:$((5005 + PORT_OFFSET))" + echo " MinIO: http://localhost:$((9001 + PORT_OFFSET)) (admin/password)" just wait [doc("Build image & code, then start. Example: just start-build 3 tabletopic")] @@ -202,11 +277,11 @@ logs-follow node="": [doc("Enter container shell (default: node 0)")] shell node="0": - @docker exec -it node-{{node}} bash + @{{COMPOSE}} exec node-{{node}} bash [doc("Run a single command in container. Example: just exec 0 ls /opt/automq/bin")] exec node *CMD: - @docker exec node-{{node}} {{CMD}} + @{{COMPOSE}} exec node-{{node}} {{CMD}} [doc("Wait for all nodes to be healthy (auto-called by start)")] wait timeout="180": @@ -217,8 +292,8 @@ wait timeout="180": NODES=$(just _node-list) [ -z "$NODES" ] && sleep 5 && ELAPSED=$((ELAPSED + 5)) && continue ALL_HEALTHY=true - for n in $NODES; do - S=$(docker inspect --format='{{ '{{' }}.State.Health.Status{{ '}}' }}' "$n" 2>/dev/null) + for svc in $NODES; do + S=$({{COMPOSE}} ps --format '{{ '{{' }}.Health{{ '}}' }}' "$svc" 2>/dev/null) [ "$S" != "healthy" ] && ALL_HEALTHY=false && break done [ "$ALL_HEALTHY" = true ] && echo "✓ All nodes healthy" && exit 0 @@ -244,7 +319,7 @@ bin *ARGS: *) PARAMS+=("$1"); shift ;; esac done - docker exec "node-${NODE}" \ + {{COMPOSE}} exec "node-${NODE}" \ env "KAFKA_HEAP_OPTS={{HEAP_OPTS}}" KAFKA_JVM_PERFORMANCE_OPTS="" JMX_PORT="" \ /opt/automq/bin/"${PARAMS[@]}" 2> >(grep -v '^SLF4J' >&2) @@ -259,7 +334,7 @@ jmx *ARGS: *) break ;; esac done - echo "$@" | docker exec -i node-${NODE} java -jar /usr/local/bin/jmxterm.jar -l localhost:9999 -n + echo "$@" | {{COMPOSE}} exec -T node-${NODE} java -jar /usr/local/bin/jmxterm.jar -l localhost:9999 -n # ==================== Shortcuts ==================== @@ -286,7 +361,7 @@ produce topic *ARGS: *) PARAMS+=("$1"); shift ;; esac done - docker exec -i "node-${NODE}" \ + {{COMPOSE}} exec -T "node-${NODE}" \ env "KAFKA_HEAP_OPTS={{HEAP_OPTS}}" KAFKA_JVM_PERFORMANCE_OPTS="" JMX_PORT="" \ /opt/automq/bin/kafka-console-producer.sh \ --bootstrap-server localhost:9092 --topic "{{topic}}" \ @@ -303,7 +378,7 @@ consume topic *ARGS: *) PARAMS+=("$1"); shift ;; esac done - docker exec -i "node-${NODE}" \ + {{COMPOSE}} exec -T "node-${NODE}" \ env "KAFKA_HEAP_OPTS={{HEAP_OPTS}}" KAFKA_JVM_PERFORMANCE_OPTS="" JMX_PORT="" \ /opt/automq/bin/kafka-console-consumer.sh \ --bootstrap-server localhost:9092 --topic "{{topic}}" \ @@ -352,24 +427,24 @@ perf-consume topic *ARGS: [doc("Attach Arthas to Kafka process. Example: just arthas 1")] arthas node="0": - @docker exec -it node-{{node}} java -jar /opt/arthas/arthas-boot.jar + @{{COMPOSE}} exec node-{{node}} java -jar /opt/arthas/arthas-boot.jar [doc("Run a single Arthas command non-interactively. Example: just arthas-exec 0 'dashboard -n 1'")] arthas-exec node cmd: #!/usr/bin/env bash - printf '%s\n' '{{cmd}}' | docker exec -i node-{{node}} bash -c \ + printf '%s\n' '{{cmd}}' | {{COMPOSE}} exec -T node-{{node}} bash -c \ 'cat > /tmp/_arthas_cmd && java -jar /opt/arthas/arthas-boot.jar --select kafka.Kafka -f /tmp/_arthas_cmd' # ==================== Chaos: Node network ==================== [doc("Network delay on node. Example: just chaos-delay 500 node-1")] chaos-delay ms="200" node="node-0": - docker exec {{node}} tc qdisc replace dev eth0 root netem delay {{ms}}ms + {{COMPOSE}} exec {{node}} tc qdisc replace dev eth0 root netem delay {{ms}}ms @echo "✓ {{ms}}ms delay on {{node}}" [doc("Packet loss on node. Example: just chaos-loss 10 node-1")] chaos-loss percent="5" node="node-0": - docker exec {{node}} tc qdisc replace dev eth0 root netem loss {{percent}}% + {{COMPOSE}} exec {{node}} tc qdisc replace dev eth0 root netem loss {{percent}}% @echo "✓ {{percent}}% packet loss on {{node}}" # ==================== Chaos: S3 (MinIO) ==================== @@ -377,45 +452,45 @@ chaos-loss percent="5" node="node-0": [doc("S3 latency on a node (only S3 traffic affected). Example: just chaos-s3-delay 500 node-0")] chaos-s3-delay ms="500" node="node-0": #!/usr/bin/env bash - MINIO_IP=$(docker exec {{node}} getent hosts minio | awk '{print $1}') - docker exec {{node}} tc qdisc del dev eth0 root 2>/dev/null || true - docker exec {{node}} tc qdisc add dev eth0 root handle 1: prio - docker exec {{node}} tc qdisc add dev eth0 parent 1:3 handle 30: netem delay {{ms}}ms - docker exec {{node}} tc filter add dev eth0 parent 1:0 protocol ip u32 match ip dst $MINIO_IP/32 flowid 1:3 + MINIO_IP=$({{COMPOSE}} exec {{node}} getent hosts minio | awk '{print $1}') + {{COMPOSE}} exec {{node}} tc qdisc del dev eth0 root 2>/dev/null || true + {{COMPOSE}} exec {{node}} tc qdisc add dev eth0 root handle 1: prio + {{COMPOSE}} exec {{node}} tc qdisc add dev eth0 parent 1:3 handle 30: netem delay {{ms}}ms + {{COMPOSE}} exec {{node}} tc filter add dev eth0 parent 1:0 protocol ip u32 match ip dst $MINIO_IP/32 flowid 1:3 echo "✓ {{ms}}ms delay to MinIO on {{node}} (other traffic unaffected)" [doc("S3 packet loss on a node (only S3 traffic affected). Example: just chaos-s3-loss 10 node-0")] chaos-s3-loss percent="5" node="node-0": #!/usr/bin/env bash - MINIO_IP=$(docker exec {{node}} getent hosts minio | awk '{print $1}') - docker exec {{node}} tc qdisc del dev eth0 root 2>/dev/null || true - docker exec {{node}} tc qdisc add dev eth0 root handle 1: prio - docker exec {{node}} tc qdisc add dev eth0 parent 1:3 handle 30: netem loss {{percent}}% - docker exec {{node}} tc filter add dev eth0 parent 1:0 protocol ip u32 match ip dst $MINIO_IP/32 flowid 1:3 + MINIO_IP=$({{COMPOSE}} exec {{node}} getent hosts minio | awk '{print $1}') + {{COMPOSE}} exec {{node}} tc qdisc del dev eth0 root 2>/dev/null || true + {{COMPOSE}} exec {{node}} tc qdisc add dev eth0 root handle 1: prio + {{COMPOSE}} exec {{node}} tc qdisc add dev eth0 parent 1:3 handle 30: netem loss {{percent}}% + {{COMPOSE}} exec {{node}} tc filter add dev eth0 parent 1:0 protocol ip u32 match ip dst $MINIO_IP/32 flowid 1:3 echo "✓ {{percent}}% packet loss to MinIO on {{node}} (other traffic unaffected)" [doc("Pause MinIO — S3 completely unavailable")] chaos-s3-down: - docker pause minio + {{COMPOSE}} pause minio @echo "✓ MinIO (S3) paused — all nodes lost S3 access" [doc("Resume MinIO")] chaos-s3-up: - docker unpause minio + {{COMPOSE}} unpause minio @echo "✓ MinIO (S3) resumed" [doc("Isolate a node from S3. Example: just chaos-s3-partition node-1")] chaos-s3-partition node="node-0": (_ensure-iptables-chain node) - docker exec {{node}} iptables -A DEVKIT -d minio -j DROP - docker exec {{node}} iptables -A DEVKIT -s minio -j DROP + {{COMPOSE}} exec {{node}} iptables -A DEVKIT -d minio -j DROP + {{COMPOSE}} exec {{node}} iptables -A DEVKIT -s minio -j DROP @echo "✓ {{node}} isolated from MinIO (S3)" [doc("Restore all nodes' S3 connectivity")] chaos-s3-partition-reset: #!/usr/bin/env bash for n in $(just _node-list); do - docker exec $n iptables -D DEVKIT -d minio -j DROP 2>/dev/null || true - docker exec $n iptables -D DEVKIT -s minio -j DROP 2>/dev/null || true + {{COMPOSE}} exec $n iptables -D DEVKIT -d minio -j DROP 2>/dev/null || true + {{COMPOSE}} exec $n iptables -D DEVKIT -s minio -j DROP 2>/dev/null || true done echo "✓ All S3 partitions removed" @@ -423,17 +498,17 @@ chaos-s3-partition-reset: [doc("Isolate two nodes. Example: just chaos-partition node-0 node-1")] chaos-partition a b: (_ensure-iptables-chain a) (_ensure-iptables-chain b) - docker exec {{a}} iptables -A DEVKIT -d {{b}} -j DROP - docker exec {{a}} iptables -A DEVKIT -s {{b}} -j DROP - docker exec {{b}} iptables -A DEVKIT -d {{a}} -j DROP - docker exec {{b}} iptables -A DEVKIT -s {{a}} -j DROP + {{COMPOSE}} exec {{a}} iptables -A DEVKIT -d {{b}} -j DROP + {{COMPOSE}} exec {{a}} iptables -A DEVKIT -s {{b}} -j DROP + {{COMPOSE}} exec {{b}} iptables -A DEVKIT -d {{a}} -j DROP + {{COMPOSE}} exec {{b}} iptables -A DEVKIT -s {{a}} -j DROP @echo "✓ {{a}} ↔ {{b}} isolated" [doc("Restore all node-to-node connectivity")] chaos-partition-reset: #!/usr/bin/env bash for n in $(just _node-list); do - docker exec $n iptables -F DEVKIT 2>/dev/null || true + {{COMPOSE}} exec $n iptables -F DEVKIT 2>/dev/null || true done echo "✓ All node partitions removed" @@ -443,11 +518,11 @@ chaos-partition-reset: chaos-reset: #!/usr/bin/env bash for n in $(just _node-list); do - docker exec $n tc qdisc del dev eth0 root 2>/dev/null || true - docker exec $n iptables -F DEVKIT 2>/dev/null || true + {{COMPOSE}} exec $n tc qdisc del dev eth0 root 2>/dev/null || true + {{COMPOSE}} exec $n iptables -F DEVKIT 2>/dev/null || true done # Unpause MinIO if paused - docker unpause minio 2>/dev/null || true + {{COMPOSE}} unpause minio 2>/dev/null || true echo "✓ All chaos rules reset (tc + iptables + unpause)" [doc("Show all active chaos rules")] @@ -455,10 +530,14 @@ chaos-status: #!/usr/bin/env bash for n in $(just _node-list); do echo "=== $n ===" - echo " tc:" && docker exec $n tc qdisc show dev eth0 - RULES=$(docker exec $n iptables -L DEVKIT -n 2>/dev/null | grep -c DROP || true) + echo " tc:" && {{COMPOSE}} exec $n tc qdisc show dev eth0 + RULES=$({{COMPOSE}} exec $n iptables -L DEVKIT -n 2>/dev/null | grep -c DROP || true) [ "$RULES" -gt 0 ] && echo " iptables: ${RULES} DROP rules in DEVKIT chain" done echo "=== minio ===" - S=$(docker inspect --format='{{ '{{' }}.State.Paused{{ '}}' }}' minio 2>/dev/null) - [ "$S" = "true" ] && echo " status: PAUSED" || echo " status: running" + S=$(docker inspect --format='{{ '{{' }}.State.Paused{{ '}}' }}' "$({{COMPOSE}} ps -q minio 2>/dev/null)" 2>/dev/null) + case "$S" in + true) echo " status: PAUSED" ;; + false) echo " status: running" ;; + *) echo " status: not running" ;; + esac diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 73990dbb34..79dd80fff6 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -98,7 +98,11 @@ versions += [ gradle: "8.10.2", grgit: "4.1.1", httpclient: "4.5.14", - jackson: "2.17.1", + // AutoMQ inject start + jackson: "2.20.0", + // jackson-annotations uses "2.20" instead of "2.20.0" + jacksonAnnotationsVersion: "2.20", + // AutoMQ inject end jacoco: "0.8.10", javassist: "3.29.2-GA", jetty: "9.4.57.v20241219", @@ -146,7 +150,10 @@ versions += [ metrics: "2.2.0", netty: "4.1.119.Final", opentelemetryProto: "1.0.0-alpha", - protobuf: "3.25.5", // a dependency of opentelemetryProto + // AutoMQ inject start + protobuf: "4.32.1", + protoGoogleCommonProtos: "2.40.0", + // AutoMQ inject end pcollections: "4.0.1", reflections: "0.10.2", reload4j: "1.2.25", @@ -160,6 +167,9 @@ versions += [ scoverage: "2.0.11", slf4j: "1.7.36", jclOverSlf4j: "1.7.36", + // AutoMQ inject start + snakeyaml: "2.5", + // AutoMQ inject end snappy: "1.1.10.5", spotbugs: "4.8.6", zinc: "1.9.2", @@ -172,18 +182,19 @@ versions += [ opentelemetrySDK: "1.40.0", opentelemetrySDKAlpha: "1.40.0-alpha", opentelemetryInstrument: "2.6.0-alpha", - oshi: "6.4.7", awsSdk:"2.29.26", - bucket4j:"8.5.0", + bucket4j:"8.10.1", jna:"5.2.0", guava:"32.0.1-jre", hdrHistogram:"2.1.12", nettyTcnativeBoringSsl: "2.0.69.Final", + grpc: "1.65.0", avro: "1.11.4", confluentSchema: "7.8.0", iceberg: "1.6.1", wire: "4.9.1", oshi: "6.8.1", + cloudevents: "4.0.1", // AutoMQ inject end // When updating the zstd version, please do as well in docker/native/native-image-configs/resource-config.json @@ -209,7 +220,9 @@ libs += [ commonsCli: "commons-cli:commons-cli:$versions.commonsCli", commonsIo: "commons-io:commons-io:$versions.commonsIo", commonsValidator: "commons-validator:commons-validator:$versions.commonsValidator", - jacksonAnnotations: "com.fasterxml.jackson.core:jackson-annotations:$versions.jackson", + // AutoMQ inject start + jacksonAnnotations: "com.fasterxml.jackson.core:jackson-annotations:$versions.jacksonAnnotationsVersion", + // AutoMQ inject end jacksonDatabind: "com.fasterxml.jackson.core:jackson-databind:$versions.jackson", jacksonDataformatCsv: "com.fasterxml.jackson.dataformat:jackson-dataformat-csv:$versions.jackson", jacksonModuleScala: "com.fasterxml.jackson.module:jackson-module-scala_$versions.baseScala:$versions.jackson", @@ -217,6 +230,10 @@ libs += [ jacksonAfterburner: "com.fasterxml.jackson.module:jackson-module-afterburner:$versions.jackson", jacksonJaxrsJsonProvider: "com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:$versions.jackson", jacksonYaml: "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:$versions.jackson", + // AutoMQ inject start + snakeyaml: "org.yaml:snakeyaml:$versions.snakeyaml", + protoGoogleCommonProtos: "com.google.api.grpc:proto-google-common-protos:$versions.protoGoogleCommonProtos", + // AutoMQ inject end jaxAnnotationApi: "javax.annotation:javax.annotation-api:$versions.jaxAnnotation", jaxbApi: "javax.xml.bind:jaxb-api:$versions.jaxb", jaxrsApi: "javax.ws.rs:javax.ws.rs-api:$versions.jaxrs", @@ -310,8 +327,10 @@ libs += [ opentelemetryExporterLogging: "io.opentelemetry:opentelemetry-exporter-logging:$versions.opentelemetrySDK", opentelemetryExporterProm: "io.opentelemetry:opentelemetry-exporter-prometheus:$versions.opentelemetrySDKAlpha", opentelemetryExporterOTLP: "io.opentelemetry:opentelemetry-exporter-otlp:$versions.opentelemetrySDK", + opentelemetryExporterSenderJdk: "io.opentelemetry:opentelemetry-exporter-sender-jdk:$versions.opentelemetrySDK", + opentelemetryExporterSenderGrpcManagedChannel: "io.opentelemetry:opentelemetry-exporter-sender-grpc-managed-channel:$versions.opentelemetrySDK", + grpcNettyShaded: "io.grpc:grpc-netty-shaded:$versions.grpc", opentelemetryJmx: "io.opentelemetry.instrumentation:opentelemetry-jmx-metrics:$versions.opentelemetryInstrument", - oshi: "com.github.oshi:oshi-core-java11:$versions.oshi", bucket4j: "com.bucket4j:bucket4j-core:$versions.bucket4j", jna: "net.java.dev.jna:jna:$versions.jna", guava: "com.google.guava:guava:$versions.guava", @@ -319,4 +338,5 @@ libs += [ kafkaAvroSerializer: "io.confluent:kafka-avro-serializer:$versions.confluentSchema", spotbugsAnnotations: "com.github.spotbugs:spotbugs-annotations:$versions.spotbugs", oshi: "com.github.oshi:oshi-core:$versions.oshi", + cloudeventsKafka: "io.cloudevents:cloudevents-kafka:$versions.cloudevents", ] diff --git a/gradle/spotbugs-exclude.xml b/gradle/spotbugs-exclude.xml index 4e212c88e2..fe85573500 100644 --- a/gradle/spotbugs-exclude.xml +++ b/gradle/spotbugs-exclude.xml @@ -636,6 +636,10 @@ For a detailed description of spotbugs bug categories, see https://spotbugs.read + + + + diff --git a/tools/src/main/java/org/apache/kafka/tools/ClusterEventsCommand.java b/tools/src/main/java/org/apache/kafka/tools/ClusterEventsCommand.java new file mode 100644 index 0000000000..db33b453ed --- /dev/null +++ b/tools/src/main/java/org/apache/kafka/tools/ClusterEventsCommand.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.kafka.tools; + +import org.apache.kafka.clients.admin.Admin; +import org.apache.kafka.clients.admin.AdminClientConfig; +import org.apache.kafka.clients.admin.ClusterEventTypeRegistry; +import org.apache.kafka.clients.admin.ClusterEventsReader; +import org.apache.kafka.common.utils.Exit; +import org.apache.kafka.common.utils.Utils; +import org.apache.kafka.server.util.CommandDefaultOptions; +import org.apache.kafka.server.util.CommandLineUtils; + +import java.io.IOException; +import java.io.PrintStream; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.cloudevents.CloudEvent; +import joptsimple.ArgumentAcceptingOptionSpec; + +/** + * CLI tool for querying the {@code __automq_cluster_events} internal topic. + * + *

When {@code --until} is not specified, runs in tail mode: continuously polls + * for new events until Ctrl-C or {@code --max-events} is reached. + * + *

+ * kafka-cluster-events.sh --bootstrap-server localhost:9092 \
+ *   --type com.automq.ops.rebalance.summary --since -24h
+ * 
+ */ +public class ClusterEventsCommand { + + private static final Duration POLL_TIMEOUT = Duration.ofSeconds(2); + + public static void main(String[] args) { + Exit.exit(mainNoExit(args, System.out)); + } + + static int mainNoExit(String[] args, PrintStream out) { + ClusterEventsCommandOptions opts = new ClusterEventsCommandOptions(args); + try { + opts.checkArgs(); + } catch (Exception e) { + out.println("Error: " + e.getMessage()); + try { + opts.parser.printHelpOn(out); + } catch (IOException ioe) { + // ignore + } + return 1; + } + + Properties props = buildAdminProps(opts, out); + if (props == null) return 1; + + try (Admin admin = Admin.create(props); + ClusterEventsReader reader = admin.describeClusterEvents(parseSinceMs(opts))) { + return pollLoop(reader, out, opts); + } catch (Exception e) { + out.println("Error: " + e.getMessage()); + return 1; + } + } + + private static Long parseSinceMs(ClusterEventsCommandOptions opts) { + return opts.options.has(opts.sinceOpt) + ? parseTimeArg(opts.options.valueOf(opts.sinceOpt)) : null; + } + + private static int pollLoop(ClusterEventsReader reader, PrintStream out, + ClusterEventsCommandOptions opts) { + EventFilter filter = EventFilter.from(opts); + AtomicBoolean running = new AtomicBoolean(true); + Runtime.getRuntime().addShutdownHook(new Thread(() -> running.set(false))); + + int count = 0; + while (running.get() && count < filter.maxEvents) { + List events = reader.poll(POLL_TIMEOUT); + for (CloudEvent event : events) { + if (filter.isPastUntil(event)) return 0; + if (filter.matches(event)) { + out.println(formatEvent(event)); + if (++count >= filter.maxEvents) return 0; + } + } + if (!filter.tailMode && reader.isPolledToEnd()) break; + } + return 0; + } + + private static final class EventFilter { + final Long untilMs; + final int maxEvents; + final String eventType; + final Pattern sourceRegex; + final Pattern subjectRegex; + final boolean tailMode; + + EventFilter(Long untilMs, int maxEvents, String eventType, + Pattern sourceRegex, Pattern subjectRegex) { + this.untilMs = untilMs; + this.maxEvents = maxEvents; + this.eventType = eventType; + this.sourceRegex = sourceRegex; + this.subjectRegex = subjectRegex; + this.tailMode = untilMs == null; + } + + static EventFilter from(ClusterEventsCommandOptions opts) { + Long untilMs = opts.options.has(opts.untilOpt) + ? parseTimeArg(opts.options.valueOf(opts.untilOpt)) : null; + int maxEvents = opts.options.has(opts.maxEventsOpt) + ? opts.options.valueOf(opts.maxEventsOpt) : Integer.MAX_VALUE; + String eventType = opts.options.has(opts.typeOpt) + ? opts.options.valueOf(opts.typeOpt) : null; + Pattern sourceRegex = opts.options.has(opts.sourceOpt) + ? Pattern.compile(opts.options.valueOf(opts.sourceOpt)) : null; + Pattern subjectRegex = opts.options.has(opts.subjectOpt) + ? Pattern.compile(opts.options.valueOf(opts.subjectOpt)) : null; + return new EventFilter(untilMs, maxEvents, eventType, sourceRegex, subjectRegex); + } + + boolean isPastUntil(CloudEvent event) { + if (untilMs == null) return false; + java.time.OffsetDateTime time = event.getTime(); + return time != null && time.toInstant().toEpochMilli() > untilMs; + } + + boolean matches(CloudEvent event) { + if (event == null) return false; + if (eventType != null && !eventType.equals(event.getType())) return false; + if (!matchesPattern(sourceRegex, event.getSource() != null ? event.getSource().toString() : null)) + return false; + return matchesPattern(subjectRegex, event.getSubject()); + } + + private static boolean matchesPattern(Pattern pattern, String value) { + if (pattern == null) return true; + return value != null && pattern.matcher(value).find(); + } + } + + private static Properties buildAdminProps(ClusterEventsCommandOptions opts, PrintStream out) { + Properties props = new Properties(); + if (opts.options.has(opts.commandConfigOpt)) { + try { + props.putAll(Utils.loadProps(opts.options.valueOf(opts.commandConfigOpt))); + } catch (IOException e) { + out.println("Error loading command config: " + e.getMessage()); + return null; + } + } + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, opts.options.valueOf(opts.bootstrapServerOpt)); + return props; + } + + /** + * Parse a time argument. Accepts: + *
    + *
  • ISO-8601 datetime: {@code 2026-04-07T10:00:00Z}
  • + *
  • Relative: {@code -1h}, {@code -24h}, {@code -7d}
  • + *
+ */ + static long parseTimeArg(String value) { + value = value.trim(); + Pattern relative = Pattern.compile("^-(\\d+)([hHdD])$"); + Matcher m = relative.matcher(value); + if (m.matches()) { + long amount = Long.parseLong(m.group(1)); + String unit = m.group(2).toLowerCase(java.util.Locale.ROOT); + long millis = unit.equals("h") ? amount * 3600_000L : amount * 86400_000L; + return System.currentTimeMillis() - millis; + } + try { + return OffsetDateTime.parse(value, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + .toInstant().toEpochMilli(); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Cannot parse time value '" + value + + "'. Use ISO-8601 (e.g. 2026-04-07T10:00:00Z) or relative (e.g. -1h, -7d)."); + } + } + + private static String formatEvent(CloudEvent event) { + StringBuilder sb = new StringBuilder(); + sb.append("type=").append(event.getType()); + sb.append(" source=").append(event.getSource()); + if (event.getSubject() != null) + sb.append(" subject=").append(event.getSubject()); + if (event.getTime() != null) + sb.append(" time=").append(event.getTime()); + ClusterEventTypeRegistry.decode(event).ifPresentOrElse( + decoded -> sb.append(" data=").append(decoded), + () -> { + io.cloudevents.CloudEventData data = event.getData(); + if (data != null) { + byte[] bytes = data.toBytes(); + if (bytes != null) + sb.append(" data=").append(new String(bytes, java.nio.charset.StandardCharsets.UTF_8)); + } + } + ); + return sb.toString(); + } + + static final class ClusterEventsCommandOptions extends CommandDefaultOptions { + final ArgumentAcceptingOptionSpec bootstrapServerOpt; + final ArgumentAcceptingOptionSpec commandConfigOpt; + final ArgumentAcceptingOptionSpec typeOpt; + final ArgumentAcceptingOptionSpec sinceOpt; + final ArgumentAcceptingOptionSpec untilOpt; + final ArgumentAcceptingOptionSpec sourceOpt; + final ArgumentAcceptingOptionSpec subjectOpt; + final ArgumentAcceptingOptionSpec maxEventsOpt; + + ClusterEventsCommandOptions(String[] args) { + super(args); + bootstrapServerOpt = parser.accepts("bootstrap-server", "REQUIRED: The Kafka server to connect to.") + .withRequiredArg().describedAs("server").ofType(String.class); + commandConfigOpt = parser.accepts("command-config", + "Property file containing configs to be passed to Admin Client.") + .withRequiredArg().describedAs("config file").ofType(String.class); + typeOpt = parser.accepts("type", "Filter by CloudEvent type (e.g. com.automq.ops.rebalance.summary).") + .withRequiredArg().ofType(String.class); + sinceOpt = parser.accepts("since", + "Only show events at or after this time. ISO-8601 or relative (e.g. -1h, -7d).") + .withRequiredArg().ofType(String.class); + untilOpt = parser.accepts("until", + "Only show events at or before this time. ISO-8601 or relative. " + + "If not set, runs in tail mode (continuously polls until Ctrl-C).") + .withRequiredArg().ofType(String.class); + sourceOpt = parser.accepts("source", "Filter by source (regex).") + .withRequiredArg().ofType(String.class); + subjectOpt = parser.accepts("subject", "Filter by subject (regex).") + .withRequiredArg().ofType(String.class); + maxEventsOpt = parser.accepts("max-events", "Maximum number of events to return.") + .withRequiredArg().ofType(Integer.class); + options = parser.parse(args); + } + + void checkArgs() { + CommandLineUtils.checkRequiredArgs(parser, options, bootstrapServerOpt); + } + } +}