hgeraldino commented on code in PR #20384: URL: https://github.com/apache/kafka/pull/20384#discussion_r3363434505
########## tools/src/test/java/org/apache/kafka/tools/ConnectInternalTopicsTest.java: ########## @@ -0,0 +1,199 @@ +/* + * 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.NewTopic; +import org.apache.kafka.common.config.ConfigResource; +import org.apache.kafka.common.test.ClusterInstance; +import org.apache.kafka.common.test.api.ClusterTest; +import org.apache.kafka.test.TestUtils; + +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ConnectInternalTopicsTest { + + private static final String CONFIG_TOPIC_NAME = "config"; + private static final String STATUS_TOPIC_NAME = "status"; + private static final String OFFSET_TOPIC_NAME = "offset"; + + @TempDir + Path workspace; + + @ClusterTest(brokers = 3) + void testCreateInternalTopicsWithDefaultValues(ClusterInstance cluster) throws Exception { + var properties = new Properties(); + properties.setProperty("bootstrap.servers", cluster.bootstrapServers()); + properties.setProperty("config.storage.topic", CONFIG_TOPIC_NAME); + properties.setProperty("status.storage.topic", STATUS_TOPIC_NAME); + properties.setProperty("offset.storage.topic", OFFSET_TOPIC_NAME); + var workerConfigPath = setupWorkerConfig(workspace.resolve("worker.properties"), properties); + var res = runCommand("create", "--worker-config", workerConfigPath.toString()); + assertEquals(0, res.returnCode); + try (var adminClient = cluster.admin()) { + waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME, STATUS_TOPIC_NAME, OFFSET_TOPIC_NAME)); + assertTopicPartitions(adminClient, CONFIG_TOPIC_NAME, 1); + assertTopicPartitions(adminClient, STATUS_TOPIC_NAME, 5); + assertTopicPartitions(adminClient, OFFSET_TOPIC_NAME, 25); + } + } + + @ClusterTest + void testNoWorkerConfig() { + var res = runCommand("create"); + assertNotEquals(0, res.returnCode); + } + + @ClusterTest + void testWorkerConfigBlank() { + var res = runCommand("create", "--worker-config", ""); + assertNotEquals(0, res.returnCode); + } + + @ClusterTest + void testWorkerConfigFileDoesNotExist() { + var nonExistentPath = workspace.resolve("nonexistent-worker.properties").toString(); + var res = runCommand("create", "--worker-config", nonExistentPath); + assertNotEquals(0, res.returnCode); + assertTrue(res.err.contains("Unable to read worker config")); + } + + @ClusterTest(brokers = 3) + void testNoTopicNamesInWorkerConfig(ClusterInstance cluster) throws IOException { + var properties = new Properties(); + properties.setProperty("bootstrap.servers", cluster.bootstrapServers()); + var configPath = setupWorkerConfig(workspace.resolve("worker-no-topics.properties"), properties); + var res = runCommand("create", "--worker-config", configPath.toString()); + assertNotEquals(0, res.returnCode); + assertEquals("Missing required configuration \"offset.storage.topic\" which has no default value.\n", res.err); + } + + @ClusterTest(brokers = 3) + void testEmptyTopicNamesInWorkerConfig(ClusterInstance cluster) throws IOException { + var properties = new Properties(); + properties.setProperty("bootstrap.servers", cluster.bootstrapServers()); + properties.setProperty("config.storage.topic", "config"); + properties.setProperty("status.storage.topic", "status"); + properties.setProperty("offset.storage.topic", ""); + var configPath = setupWorkerConfig(workspace.resolve("worker-empty-topics.properties"), properties); + var res = runCommand("create", "--worker-config", configPath.toString()); + assertNotEquals(0, res.returnCode); + assertEquals("Must specify non-empty value for required internal topic config: 'offset.storage.topic'.\n", res.err); + } + + @ClusterTest(brokers = 3) + void testTopicConfigOverrides(ClusterInstance cluster) throws Exception { + var properties = new Properties(); + properties.setProperty("bootstrap.servers", cluster.bootstrapServers()); + properties.setProperty("config.storage.topic", CONFIG_TOPIC_NAME); + properties.setProperty("config.storage.retention.ms", "1000"); + properties.setProperty("status.storage.topic", STATUS_TOPIC_NAME); + properties.setProperty("status.storage.retention.ms", "2000"); + properties.setProperty("offset.storage.topic", OFFSET_TOPIC_NAME); + properties.setProperty("offset.storage.retention.ms", "3000"); + var configPath = setupWorkerConfig(workspace.resolve("worker-topic-overrides.properties"), properties); + var res = runCommand("create", "--worker-config", configPath.toString()); + assertEquals(0, res.returnCode); + try (var adminClient = cluster.admin()) { + waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME, STATUS_TOPIC_NAME, OFFSET_TOPIC_NAME)); + assertTopicConfig(adminClient, CONFIG_TOPIC_NAME, "retention.ms", "1000"); + assertTopicConfig(adminClient, STATUS_TOPIC_NAME, "retention.ms", "2000"); + assertTopicConfig(adminClient, OFFSET_TOPIC_NAME, "retention.ms", "3000"); + } + } + + @ClusterTest(brokers = 3) + void testCreateMissingTopics(ClusterInstance cluster) throws Exception { + try (var adminClient = cluster.admin()) { + adminClient.createTopics(Collections.singleton( + new NewTopic(CONFIG_TOPIC_NAME, 1, (short) 1) + .configs(Map.of("retention.ms", "1000")) + )); + waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME)); + } + var properties = new Properties(); + properties.setProperty("bootstrap.servers", cluster.bootstrapServers()); + properties.setProperty("config.storage.topic", CONFIG_TOPIC_NAME); + properties.setProperty("status.storage.topic", STATUS_TOPIC_NAME); + properties.setProperty("offset.storage.topic", OFFSET_TOPIC_NAME); + var configPath = setupWorkerConfig(workspace.resolve("worker-partial-topics.properties"), properties); + var res = runCommand("create", "--worker-config", configPath.toString()); + assertEquals(0, res.returnCode); + try (var adminClient = cluster.admin()) { + waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME, STATUS_TOPIC_NAME, OFFSET_TOPIC_NAME)); + assertTopicConfig(adminClient, CONFIG_TOPIC_NAME, "retention.ms", "1000"); Review Comment: let's add an assertion for replication factor as well (the default is 3, but the test is setting it to 1) ########## connect/runtime/src/main/java/org/apache/kafka/connect/runtime/WorkerConfig.java: ########## @@ -401,6 +401,10 @@ public boolean connectorOffsetsTopicsPermitted() { return false; } + public boolean internalTopicsCreationEnabled() { + return false; Review Comment: Is this method needed? The Standalone doesn't use it, so maybe it should only exist in the `DistributedConfig` class. If it turns out it is indeed needed, then how about defaulting it to `true` (to retain current behavior) ########## connect/runtime/src/main/java/org/apache/kafka/connect/runtime/distributed/DistributedConfig.java: ########## @@ -187,6 +188,12 @@ public final class DistributedConfig extends WorkerConfig { public static final String CONNECT_PROTOCOL_DOC = "Compatibility mode for Kafka Connect Protocol"; public static final String CONNECT_PROTOCOL_DEFAULT = ConnectProtocolCompatibility.SESSIONED.toString(); + + public static final String INTERNAL_TOPICS_CREATION_ENABLE_CONFIG = "internal.topics.creation.enable"; Review Comment: The KIP mentions `internal.topics.automatic.creation.enable` as the configuration property. Let's make sure to update the KIP so it reflects the new value (or rename the property) ########## tools/src/main/java/org/apache/kafka/tools/ConnectInternalTopics.java: ########## @@ -0,0 +1,244 @@ +/* + * 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.common.config.AbstractConfig; +import org.apache.kafka.common.config.ConfigDef; +import org.apache.kafka.common.config.ConfigException; +import org.apache.kafka.common.config.TopicConfig; +import org.apache.kafka.common.utils.Exit; +import org.apache.kafka.common.utils.Utils; +import org.apache.kafka.connect.runtime.distributed.DistributedConfig; +import org.apache.kafka.connect.util.SharedTopicAdmin; +import org.apache.kafka.connect.util.TopicAdmin; + +import net.sourceforge.argparse4j.ArgumentParsers; +import net.sourceforge.argparse4j.inf.ArgumentParser; +import net.sourceforge.argparse4j.inf.ArgumentParserException; +import net.sourceforge.argparse4j.inf.Namespace; + +import java.io.IOException; +import java.io.PrintStream; +import java.util.HashMap; +import java.util.Map; + +import static net.sourceforge.argparse4j.impl.Arguments.store; + +public class ConnectInternalTopics { + + private static final String CREATE_COMMAND = "create"; + + public static void main(String[] args) { + Exit.exit(mainNoExit(args, System.out, System.err)); + } + + static int mainNoExit(String[] args, PrintStream out, PrintStream err) { + var parser = parser(); + try { + var namespace = parser.parseArgs(args); + var workerProperties = parseWorkerProperties(parser, namespace); + out.println("Parsed arguments and loaded worker properties"); + execute(parser, namespace, workerProperties, out, err); + out.println("Command executed successfully"); + return 0; + } catch (ArgumentParserException e) { + parser.handleError(e); + return 1; + } catch (TerseException | ConfigException e) { + err.println(e.getMessage()); + return 2; + } catch (Throwable e) { + err.println("Unexpected error: " + e.getMessage()); + err.println(Utils.stackTrace(e)); + return 3; + } + } + + private static void execute(ArgumentParser parser, Namespace namespace, Map<String, String> workerProperties, PrintStream out, PrintStream err) throws ArgumentParserException { + var subcommand = namespace.getString("subcommand"); + out.println("Subcommand: " + subcommand); + if (subcommand == null) { + throw new ArgumentParserException("No subcommand specified", parser); + } + if (CREATE_COMMAND.equals(subcommand)) { + var internalTopicsConfig = new InternalTopicsConfig(workerProperties); + internalTopicsConfig.validateTopicNames(); + out.println("Running create command for internal topics"); + runCommand(internalTopicsConfig, out, err); + } else { + throw new ArgumentParserException("Unrecognized subcommand: '" + subcommand + "'", parser); + } + } + + private static void runCommand(InternalTopicsConfig config, PrintStream out, PrintStream err) { + var adminProps = new HashMap<>(config.originals()); + out.println("Admin properties loaded for topic admin"); + try (var sharedAdmin = new SharedTopicAdmin(adminProps)) { + createInternalTopic(sharedAdmin, buildOffsetTopicSettings(config, out), out, err); + createInternalTopic(sharedAdmin, buildConfigTopicSettings(config, out), out, err); + createInternalTopic(sharedAdmin, buildStatusTopicSettings(config, out), out, err); + } + } + + @SuppressWarnings("unused") + private static void createInternalTopic(SharedTopicAdmin sharedAdmin, TopicSettings settings, PrintStream out, PrintStream err) { + out.println("Creating internal topic: " + settings.topicName); + var topicDescription = TopicAdmin.defineTopic(settings.topicName) + .config(settings.topicSettings) + .compacted() + .partitions(settings.partitions) + .replicationFactor(settings.replicationFactor) + .build(); + sharedAdmin.topicAdmin().createTopics(topicDescription); + out.println("Created internal topic: " + settings.topicName); + } + + private static TopicSettings buildOffsetTopicSettings(InternalTopicsConfig config, PrintStream out) { + return new TopicSettings( + config.getString(DistributedConfig.OFFSET_STORAGE_TOPIC_CONFIG), + config.topicSettings(DistributedConfig.OFFSET_STORAGE_PREFIX, out), + config.getInt(DistributedConfig.OFFSET_STORAGE_PARTITIONS_CONFIG), + config.getShort(DistributedConfig.OFFSET_STORAGE_REPLICATION_FACTOR_CONFIG) + ); + } + + private static TopicSettings buildConfigTopicSettings(InternalTopicsConfig config, PrintStream out) { + return new TopicSettings( + config.getString(DistributedConfig.CONFIG_TOPIC_CONFIG), + config.topicSettings(DistributedConfig.CONFIG_STORAGE_PREFIX, out), + 1, + config.getShort(DistributedConfig.CONFIG_STORAGE_REPLICATION_FACTOR_CONFIG) + ); + } + + private static TopicSettings buildStatusTopicSettings(InternalTopicsConfig config, PrintStream out) { + return new TopicSettings( + config.getString(DistributedConfig.STATUS_STORAGE_TOPIC_CONFIG), + config.topicSettings(DistributedConfig.STATUS_STORAGE_PREFIX, out), + config.getInt(DistributedConfig.STATUS_STORAGE_PARTITIONS_CONFIG), + config.getShort(DistributedConfig.STATUS_STORAGE_REPLICATION_FACTOR_CONFIG) + ); + } + + private record TopicSettings(String topicName, Map<String, Object> topicSettings, int partitions, + short replicationFactor) { + } + + private static Map<String, String> parseWorkerProperties(ArgumentParser parser, Namespace namespace) throws ArgumentParserException, TerseException { + String workerConfigPath = namespace.getString("worker_config"); + if (workerConfigPath == null || workerConfigPath.isBlank()) { + throw new ArgumentParserException("--worker-config must be specified and non-blank", parser); + } + + try { + return Utils.propsToStringMap(Utils.loadProps(workerConfigPath)); + } catch (IOException e) { + throw new TerseException("Unable to read worker config at " + workerConfigPath); + } + } + + private static ArgumentParser parser() { + var parser = ArgumentParsers.newArgumentParser("connect-internal-topics") + .defaultHelp(true) + .description("Manage internal topics required by Kafka Connect clusters (config, status, and offset topics)."); + + parser.addSubparsers() + .description("Create internal topics required for Kafka Connect operation using the provided worker configuration.") + .dest("subcommand") + .addParser(CREATE_COMMAND) + .addArgument("--worker-config") + .setDefault("") + .type(String.class) + .action(store()) + .help("Path to a Connect worker configuration file. This file must define the internal topic names and connection information for the Kafka cluster."); + + return parser; + } + + private static class InternalTopicsConfig extends AbstractConfig { + private static final ConfigDef CONFIG_DEF = new ConfigDef() + .define(DistributedConfig.OFFSET_STORAGE_TOPIC_CONFIG, + ConfigDef.Type.STRING, + ConfigDef.Importance.HIGH, + "") + .define(DistributedConfig.OFFSET_STORAGE_PARTITIONS_CONFIG, + ConfigDef.Type.INT, + 25, Review Comment: These defaults are copied from `DistributedConfig` it seems. Should we introduce constants instead, so they don't drift in the future? ########## tools/src/main/java/org/apache/kafka/tools/ConnectInternalTopics.java: ########## @@ -0,0 +1,244 @@ +/* + * 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.common.config.AbstractConfig; +import org.apache.kafka.common.config.ConfigDef; +import org.apache.kafka.common.config.ConfigException; +import org.apache.kafka.common.config.TopicConfig; +import org.apache.kafka.common.utils.Exit; +import org.apache.kafka.common.utils.Utils; +import org.apache.kafka.connect.runtime.distributed.DistributedConfig; +import org.apache.kafka.connect.util.SharedTopicAdmin; +import org.apache.kafka.connect.util.TopicAdmin; + +import net.sourceforge.argparse4j.ArgumentParsers; +import net.sourceforge.argparse4j.inf.ArgumentParser; +import net.sourceforge.argparse4j.inf.ArgumentParserException; +import net.sourceforge.argparse4j.inf.Namespace; + +import java.io.IOException; +import java.io.PrintStream; +import java.util.HashMap; +import java.util.Map; + +import static net.sourceforge.argparse4j.impl.Arguments.store; + +public class ConnectInternalTopics { + + private static final String CREATE_COMMAND = "create"; + + public static void main(String[] args) { + Exit.exit(mainNoExit(args, System.out, System.err)); + } + + static int mainNoExit(String[] args, PrintStream out, PrintStream err) { + var parser = parser(); + try { + var namespace = parser.parseArgs(args); + var workerProperties = parseWorkerProperties(parser, namespace); + out.println("Parsed arguments and loaded worker properties"); + execute(parser, namespace, workerProperties, out, err); + out.println("Command executed successfully"); + return 0; + } catch (ArgumentParserException e) { + parser.handleError(e); + return 1; + } catch (TerseException | ConfigException e) { + err.println(e.getMessage()); + return 2; + } catch (Throwable e) { + err.println("Unexpected error: " + e.getMessage()); + err.println(Utils.stackTrace(e)); + return 3; + } + } + + private static void execute(ArgumentParser parser, Namespace namespace, Map<String, String> workerProperties, PrintStream out, PrintStream err) throws ArgumentParserException { + var subcommand = namespace.getString("subcommand"); + out.println("Subcommand: " + subcommand); + if (subcommand == null) { + throw new ArgumentParserException("No subcommand specified", parser); + } + if (CREATE_COMMAND.equals(subcommand)) { + var internalTopicsConfig = new InternalTopicsConfig(workerProperties); + internalTopicsConfig.validateTopicNames(); + out.println("Running create command for internal topics"); + runCommand(internalTopicsConfig, out, err); + } else { + throw new ArgumentParserException("Unrecognized subcommand: '" + subcommand + "'", parser); + } + } + + private static void runCommand(InternalTopicsConfig config, PrintStream out, PrintStream err) { + var adminProps = new HashMap<>(config.originals()); + out.println("Admin properties loaded for topic admin"); + try (var sharedAdmin = new SharedTopicAdmin(adminProps)) { + createInternalTopic(sharedAdmin, buildOffsetTopicSettings(config, out), out, err); + createInternalTopic(sharedAdmin, buildConfigTopicSettings(config, out), out, err); + createInternalTopic(sharedAdmin, buildStatusTopicSettings(config, out), out, err); + } + } + + @SuppressWarnings("unused") + private static void createInternalTopic(SharedTopicAdmin sharedAdmin, TopicSettings settings, PrintStream out, PrintStream err) { Review Comment: nit; the `err` PrintStream is passed down all the way from `mainNoExit(...)` but never used ########## connect/runtime/src/main/java/org/apache/kafka/connect/storage/KafkaTopicBasedBackingStore.java: ########## @@ -39,17 +42,35 @@ public abstract class KafkaTopicBasedBackingStore { Consumer<TopicAdmin> topicInitializer(String topic, NewTopic topicDescription, WorkerConfig config, Time time) { return admin -> { - log.debug("Creating Connect internal topic for {}", getTopicPurpose()); - // Create the topic if it doesn't exist - Set<String> newTopics = createTopics(topicDescription, admin, config, time); - if (!newTopics.contains(topic)) { - // It already existed, so check that the topic cleanup policy is compact only and not delete - log.debug("Using admin client to check cleanup policy of '{}' topic is '{}'", topic, TopicConfig.CLEANUP_POLICY_COMPACT); - admin.verifyTopicCleanupPolicyOnlyCompact(topic, getTopicConfig(), getTopicPurpose()); + if (config.internalTopicsCreationEnabled()) { + log.debug("Creating Connect internal topic for {}", getTopicPurpose()); + // Create the topic if it doesn't exist + Set<String> newTopics = createTopics(topicDescription, admin, config, time); + if (!newTopics.contains(topic)) { + verifyTopicConfig(topic, admin); + } + } else { + log.debug("Skipping creation of Connect internal topic for {} because automatic topic creation is disabled", getTopicPurpose()); + Map<String, TopicDescription> existing = admin.describeTopics(topic); + if (existing.isEmpty()) { + String msg = String.format("Topic '%s' specified via the '%s' property is missing." + + " The config '%s' is set to '%s', so automatic creation of internal topics is disabled." + + " Either enable automatic creation or create the topics manually before starting the worker.", + topic, getTopicConfig(), DistributedConfig.INTERNAL_TOPICS_CREATION_ENABLE_CONFIG, config.internalTopicsCreationEnabled()); Review Comment: ```suggestion String msg = String.format("Topic '%s' specified via the '%s' property is missing." + " The config '%s' is set to 'false', so automatic creation of internal topics is disabled." + " Either enable automatic creation or create the topics manually before starting the worker.", topic, getTopicConfig(), DistributedConfig.INTERNAL_TOPICS_CREATION_ENABLE_CONFIG); ``` internalTopicsCreationEnabled() is always false in this branch -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
