This is an automated email from the ASF dual-hosted git repository.

mjsax pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new c9bfcf02d22 KAFKA-20719: Add integration test for sticky task 
assignment on startup (#22630)
c9bfcf02d22 is described below

commit c9bfcf02d22c7a1ac2d40ab53e24eee2847532cb
Author: Matthias J. Sax <[email protected]>
AuthorDate: Tue Jun 23 16:51:06 2026 -0700

    KAFKA-20719: Add integration test for sticky task assignment on startup 
(#22630)
    
    When a new Kafka Streams clients start ups and joins a consumer group,
    it is supposed to encode previously assigned task information, based on
    its local state directory.
    
    The sticky-task-assignor (both classic and streams groups), should use
    this information to avoid unncessary task/state movement.
    
    This PR adds a corresponding integration test to verify if the task
    assignment on startup is indeed sticky.
    
    Reviewers: Lucas Brutschy <[email protected]>
---
 .../ColdStartStickinessIntegrationTest.java        | 266 +++++++++++++++++++++
 1 file changed, 266 insertions(+)

diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/ColdStartStickinessIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/ColdStartStickinessIntegrationTest.java
new file mode 100644
index 00000000000..e9d406cb150
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/ColdStartStickinessIntegrationTest.java
@@ -0,0 +1,266 @@
+/*
+ * 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.streams.integration;
+
+import org.apache.kafka.clients.CommonClientConfigs;
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.common.serialization.IntegerDeserializer;
+import org.apache.kafka.common.serialization.IntegerSerializer;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.utils.Utils;
+import org.apache.kafka.coordinator.group.GroupCoordinatorConfig;
+import org.apache.kafka.streams.GroupProtocol;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import org.apache.kafka.streams.integration.utils.IntegrationTestUtils;
+import 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.TrackingStateRestoreListener;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.streams.kstream.Materialized;
+import org.apache.kafka.streams.kstream.Produced;
+import 
org.apache.kafka.streams.processor.internals.assignment.LegacyStickyTaskAssignor;
+import org.apache.kafka.streams.state.Stores;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.TestInfo;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Properties;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static java.util.Arrays.asList;
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.purgeLocalStreamsState;
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.startApplicationAndWaitUntilRunning;
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.waitForEmptyConsumerGroup;
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.waitForEmptyStreamGroup;
+import static 
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.waitUntilMinKeyValueRecordsReceived;
+import static org.apache.kafka.streams.utils.TestUtils.safeUniqueTestName;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+
+@Timeout(600)
+@Tag("integration")
+public class ColdStartStickinessIntegrationTest {
+
+    private static final int NUM_BROKERS = 1;
+    private static final int NUM_PARTITIONS = 4;
+    private static final int NUM_KEYS = 1_000;
+    private static final int INITIAL_REBALANCE_DELAY_MS = 5_000;
+    private static final Pattern TASK_DIR = Pattern.compile("\\d+_\\d+");
+
+    public static final EmbeddedKafkaCluster CLUSTER;
+    static {
+        final Properties brokerProps = new Properties();
+        
brokerProps.put(GroupCoordinatorConfig.GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG, 
Integer.toString(INITIAL_REBALANCE_DELAY_MS));
+        
brokerProps.put(GroupCoordinatorConfig.STREAMS_GROUP_INITIAL_REBALANCE_DELAY_MS_CONFIG,
 Integer.toString(INITIAL_REBALANCE_DELAY_MS));
+        CLUSTER = new EmbeddedKafkaCluster(NUM_BROKERS, brokerProps);
+    }
+
+    private static Admin admin;
+
+    @BeforeAll
+    public static void startCluster() throws IOException {
+        CLUSTER.start();
+        final Properties adminConfig = new Properties();
+        adminConfig.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        admin = Admin.create(adminConfig);
+    }
+
+    @AfterAll
+    public static void closeCluster() {
+        Utils.closeQuietly(admin, "admin");
+        CLUSTER.stop();
+    }
+
+    private String appId;
+    private String inputTopic;
+    private String outputTopic;
+    private final List<Properties> streamsConfigurations = new ArrayList<>();
+
+    @BeforeEach
+    public void createTopics(final TestInfo testInfo) throws 
InterruptedException {
+        appId = safeUniqueTestName(testInfo);
+        inputTopic = appId + "-input";
+        outputTopic = appId + "-output";
+        CLUSTER.createTopic(inputTopic, NUM_PARTITIONS, 1);
+        CLUSTER.createTopic(outputTopic, NUM_PARTITIONS, 1);
+    }
+
+    @AfterEach
+    public void cleanup() throws Exception {
+        purgeLocalStreamsState(streamsConfigurations);
+        streamsConfigurations.clear();
+        CLUSTER.deleteAllTopics();
+    }
+
+    @Disabled("Reproduces KAFKA-20719; enable once fixed")
+    @ParameterizedTest
+    @ValueSource(booleans = {false, true})
+    public void shouldStickToLocalStateOnColdStart(final boolean 
streamsProtocol) throws Exception {
+        final Properties props1 = props(streamsProtocol, "-1");
+        final Properties props2 = props(streamsProtocol, "-2");
+
+        // Phase 1: build committed local state for all 8 tasks across the two 
instances.
+        produceInput();
+        final KafkaStreams streams1 = new KafkaStreams(topology(), props1);
+        final KafkaStreams streams2 = new KafkaStreams(topology(), props2);
+        try {
+            startApplicationAndWaitUntilRunning(asList(streams1, streams2), 
Duration.ofSeconds(60));
+            waitUntilMinKeyValueRecordsReceived(consumerConfig(), outputTopic, 
NUM_KEYS, 120_000L);
+        } finally {
+            streams1.close(Duration.ofSeconds(60));
+            streams2.close(Duration.ofSeconds(60));
+        }
+
+        // Make sure the group is fully drained before restarting, so we get a 
proper cold start
+        if (streamsProtocol) {
+            waitForEmptyStreamGroup(admin, appId, 60_000L);
+        } else {
+            waitForEmptyConsumerGroup(admin, appId, 60_000L);
+        }
+
+        // change task layout across both instances, to differ from "empty 
state" assignment
+        final File appDir1 = new 
File(props1.getProperty(StreamsConfig.STATE_DIR_CONFIG), appId);
+        final File appDir2 = new 
File(props2.getProperty(StreamsConfig.STATE_DIR_CONFIG), appId);
+        relayoutTaskDirectoriesByPartitionHalves(appDir1, appDir2);
+
+        // Phase 2: cold restart: should be sticky and not restore anything
+        final TrackingStateRestoreListener restore1 = new 
TrackingStateRestoreListener();
+        final TrackingStateRestoreListener restore2 = new 
TrackingStateRestoreListener();
+        final KafkaStreams restarted1 = new KafkaStreams(topology(), props1);
+        final KafkaStreams restarted2 = new KafkaStreams(topology(), props2);
+        restarted1.setGlobalStateRestoreListener(restore1);
+        restarted2.setGlobalStateRestoreListener(restore2);
+        try {
+            startApplicationAndWaitUntilRunning(asList(restarted1, 
restarted2), Duration.ofSeconds(60));
+
+            final long totalRestored = restore1.totalNumRestored() + 
restore2.totalNumRestored();
+            assertThat(
+                "Tasks should be assigned to the instance that already holds 
their state, so nothing is restored",
+                totalRestored,
+                equalTo(0L));
+        } finally {
+            restarted1.close(Duration.ofSeconds(60));
+            restarted2.close(Duration.ofSeconds(60));
+        }
+    }
+
+    private Properties props(final boolean streamsProtocol, final String 
stateDirSuffix) {
+        final Properties config = new Properties();
+        config.put(StreamsConfig.APPLICATION_ID_CONFIG, appId);
+        config.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        config.put(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory(appId + stateDirSuffix).getPath());
+        config.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, 
Serdes.IntegerSerde.class);
+        config.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, 
Serdes.IntegerSerde.class);
+        config.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 1000L);
+        if (streamsProtocol) {
+            config.put(StreamsConfig.GROUP_PROTOCOL_CONFIG, 
GroupProtocol.STREAMS.name());
+        } else {
+            
config.put(StreamsConfig.InternalConfig.INTERNAL_TASK_ASSIGNOR_CLASS, 
LegacyStickyTaskAssignor.class.getName());
+        }
+        streamsConfigurations.add(config);
+        return config;
+    }
+
+    private Topology topology() {
+        final StreamsBuilder builder = new StreamsBuilder();
+        builder
+            .table(
+                inputTopic,
+                Consumed.with(Serdes.Integer(), Serdes.Integer()),
+                Materialized.<Integer, 
Integer>as(Stores.persistentKeyValueStore("store"))
+                    .withKeySerde(Serdes.Integer())
+                    .withValueSerde(Serdes.Integer())
+                    .withCachingDisabled())
+            .toStream()
+            .to(outputTopic, Produced.with(Serdes.Integer(), 
Serdes.Integer()));
+        return builder.build();
+    }
+
+    private void produceInput() {
+        final Properties producerConfig = new Properties();
+        producerConfig.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        producerConfig.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, 
IntegerSerializer.class);
+        producerConfig.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, 
IntegerSerializer.class);
+        final List<KeyValue<Integer, Integer>> data = IntStream.range(0, 
NUM_KEYS)
+            .mapToObj(i -> KeyValue.pair(i, i))
+            .collect(Collectors.toList());
+        IntegrationTestUtils.produceKeyValuesSynchronously(inputTopic, data, 
producerConfig, CLUSTER.time);
+    }
+
+    private Properties consumerConfig() {
+        final Properties config = new Properties();
+        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        config.put(ConsumerConfig.GROUP_ID_CONFIG, appId + "-verifier");
+        config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
+        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, 
IntegerDeserializer.class);
+        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, 
IntegerDeserializer.class);
+        return config;
+    }
+
+    /*
+     * Sticky-assignor (both "classic" and "streams") use a "least loaded 
greedy" strategy,
+     * resulting in the following layout (task are sorted and assigned):
+     *  - instance A: 0_0, 0_2
+     *  - instance B: 0_1, 0_3
+     *
+     * We change the layout to
+     *  - instance A: 0_0, 0_1
+     *  - instance B: 0_2, 0_3
+     */
+    private static void relayoutTaskDirectoriesByPartitionHalves(final File 
appDir1, final File appDir2) throws IOException {
+        final List<File> taskDirs = new ArrayList<>();
+        taskDirs.addAll(listTaskDirectories(appDir1));
+        taskDirs.addAll(listTaskDirectories(appDir2));
+        assertThat("expected one state directory per task", taskDirs.size(), 
equalTo(NUM_PARTITIONS));
+
+        for (final File taskDir : taskDirs) {
+            final int partition = 
Integer.parseInt(taskDir.getName().substring(taskDir.getName().indexOf('_') + 
1));
+            final File target = partition < NUM_PARTITIONS / 2 ? appDir1 : 
appDir2;
+            if (!taskDir.getParentFile().equals(target)) {
+                Files.move(taskDir.toPath(), new File(target, 
taskDir.getName()).toPath());
+            }
+        }
+    }
+
+    private static List<File> listTaskDirectories(final File appDir) {
+        final File[] dirs = appDir.listFiles(file -> file.isDirectory() && 
TASK_DIR.matcher(file.getName()).matches());
+        return dirs == null ? List.of() : Arrays.asList(dirs);
+    }
+}

Reply via email to