This is an automated email from the ASF dual-hosted git repository.
bbejeck 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 1e3c9932add KAFKA-20501: Add IQv1 isolation-level integration tests
(#22957)
1e3c9932add is described below
commit 1e3c9932add71fc2aecf374ebb99e1bf806c2fc3
Author: Nick Telford <[email protected]>
AuthorDate: Tue Aug 4 18:09:03 2026 +0100
KAFKA-20501: Add IQv1 isolation-level integration tests (#22957)
The interactive-query isolation-level support added under KIP-892 spans
many store implementations, but has so far only been covered by unit
tests. That leaves the end-to-end behaviour — that a READ_COMMITTED
query observes only committed records while a READ_UNCOMMITTED query
sees uncommitted writes — unverified against a running Streams
application.
This adds end-to-end IQv1 integration tests exercising all store shapes
(in-memory and persistent key-value, window, and session stores, plus
persistent versioned stores). Shared harness setup lives in
`IsolationLevelIntegrationFixtures`, which the IQv2 counterpart
(KAFKA-20502, stacked on this branch) also reuses.
Reviewers: Bill Bejeck <[email protected]>
---
.../IQv1IsolationLevelIntegrationTest.java | 395 +++++++++++++++++++++
.../IsolationLevelIntegrationFixtures.java | 154 ++++++++
2 files changed, 549 insertions(+)
diff --git
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv1IsolationLevelIntegrationTest.java
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv1IsolationLevelIntegrationTest.java
new file mode 100644
index 00000000000..7a2b876c86a
--- /dev/null
+++
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv1IsolationLevelIntegrationTest.java
@@ -0,0 +1,395 @@
+/*
+ * 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.common.IsolationLevel;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.StoreQueryParameters;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import org.apache.kafka.streams.integration.utils.IntegrationTestUtils;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.kstream.internals.SessionWindow;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.Record;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.QueryableStoreType;
+import org.apache.kafka.streams.state.QueryableStoreTypes;
+import org.apache.kafka.streams.state.ReadOnlyKeyValueStore;
+import org.apache.kafka.streams.state.ReadOnlySessionStore;
+import org.apache.kafka.streams.state.ReadOnlyWindowStore;
+import org.apache.kafka.streams.state.SessionStore;
+import org.apache.kafka.streams.state.StoreBuilder;
+import org.apache.kafka.streams.state.Stores;
+import org.apache.kafka.streams.state.WindowStore;
+import org.apache.kafka.streams.state.WindowStoreIterator;
+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.Tag;
+import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.COMMIT_WAIT;
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.StallGate;
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.TEST_KEY;
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.TEST_TIMESTAMP;
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.TEST_VALUE;
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.baseStreamsConfig;
+import static
org.apache.kafka.streams.integration.IsolationLevelIntegrationFixtures.sendOne;
+import static
org.apache.kafka.streams.integration.utils.IntegrationTestUtils.startApplicationAndWaitUntilRunning;
+import static org.apache.kafka.test.TestUtils.waitForCondition;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * End-to-end check that {@link
StreamsConfig#DEFAULT_INTERACTIVE_QUERY_ISOLATION_LEVEL_CONFIG} takes
+ * effect for IQv1 composite stores.
+ *
+ * <p>The test exercises the full wiring path:
+ * <ol>
+ * <li>{@link KafkaStreams#store(StoreQueryParameters)} returns a composite
read-only store.</li>
+ * <li>That composite resolves each underlying terminal store through {@code
readOnly(configuredDefault)}
+ * — for an in-memory transactional store the view either consults the
transaction buffer or reads
+ * directly from the backing {@code NavigableMap}; for a persistent
RocksDB store it selects the
+ * committed vs pending {@code DBAccessor}.</li>
+ * <li>The terminal store's view honours the requested level, hiding or
exposing writes that have been
+ * staged in the transaction buffer but not yet flushed by a Streams
commit.</li>
+ * </ol>
+ *
+ * <p>Every IQv1 composite store family that carries a {@code
readOnly(IsolationLevel)} override is covered,
+ * across both in-memory and persistent backings: {@link
ReadOnlyKeyValueStore}, {@link ReadOnlyWindowStore}
+ * and {@link ReadOnlySessionStore} (see {@link Shape}). Versioned stores have
no IQv1
+ * {@code QueryableStoreType}, so they are exercised by the IQv2 companion
test only.
+ */
+@Tag("integration")
+@Timeout(600)
+public class IQv1IsolationLevelIntegrationTest {
+
+ private static final int NUM_BROKERS = 1;
+ private static final String INPUT_TOPIC = "iq-iso-v1-input";
+ private static final String KV_STORE = "iq-iso-v1-kv";
+ private static final String WINDOW_STORE = "iq-iso-v1-window";
+ private static final String SESSION_STORE = "iq-iso-v1-session";
+ private static final Duration WINDOW_SIZE = Duration.ofSeconds(1);
+ private static final Duration WINDOW_RETENTION = Duration.ofHours(1);
+ private static final Duration SESSION_RETENTION = Duration.ofHours(1);
+
+ public static final EmbeddedKafkaCluster CLUSTER =
+ new EmbeddedKafkaCluster(NUM_BROKERS,
IsolationLevelIntegrationFixtures.singleBrokerEosOverrides());
+
+ private static final AtomicInteger APP_COUNTER = new AtomicInteger();
+
+ /**
+ * Discriminates the IQv1 composite store families and backings. Each
constant knows how to build the
+ * StoreBuilder, stage a write into its store, and read a single value
back through a composite view at a
+ * given isolation level — keeping the test body shape-free.
+ */
+ enum Shape {
+ IN_MEMORY_KV(KV_STORE, Shape::buildInMemoryKvStore,
QueryableStoreTypes.keyValueStore(),
+ Shape::stageKvWrite, Shape::readKv),
+ PERSISTENT_KV(KV_STORE, Shape::buildPersistentKvStore,
QueryableStoreTypes.keyValueStore(),
+ Shape::stageKvWrite, Shape::readKv),
+ IN_MEMORY_WINDOW(WINDOW_STORE, Shape::buildInMemoryWindowStore,
QueryableStoreTypes.windowStore(),
+ Shape::stageWindowWrite, Shape::readWindow),
+ PERSISTENT_WINDOW(WINDOW_STORE, Shape::buildPersistentWindowStore,
QueryableStoreTypes.windowStore(),
+ Shape::stageWindowWrite, Shape::readWindow),
+ IN_MEMORY_SESSION(SESSION_STORE, Shape::buildInMemorySessionStore,
QueryableStoreTypes.sessionStore(),
+ Shape::stageSessionWrite, Shape::readSession),
+ PERSISTENT_SESSION(SESSION_STORE, Shape::buildPersistentSessionStore,
QueryableStoreTypes.sessionStore(),
+ Shape::stageSessionWrite, Shape::readSession);
+
+ final String storeName;
+ final Function<String, StoreBuilder<?>> builder;
+ final QueryableStoreType<?> queryableType;
+ final WriteStep writeStep;
+ final Reader reader;
+
+ Shape(final String storeName,
+ final Function<String, StoreBuilder<?>> builder,
+ final QueryableStoreType<?> queryableType,
+ final WriteStep writeStep,
+ final Reader reader) {
+ this.storeName = storeName;
+ this.builder = builder;
+ this.queryableType = queryableType;
+ this.writeStep = writeStep;
+ this.reader = reader;
+ }
+
+ StoreBuilder<?> store() {
+ return builder.apply(storeName);
+ }
+
+ /** Align an event timestamp to the start of its WINDOW_SIZE window. */
+ private static long windowStartFor(final long timestamp) {
+ return timestamp - (timestamp % WINDOW_SIZE.toMillis());
+ }
+
+ private static StoreBuilder<?> buildInMemoryKvStore(final String name)
{
+ return Stores.keyValueStoreBuilder(
+ Stores.inMemoryKeyValueStore(name), Serdes.Integer(),
Serdes.Integer());
+ }
+
+ private static StoreBuilder<?> buildPersistentKvStore(final String
name) {
+ return Stores.keyValueStoreBuilder(
+ Stores.persistentKeyValueStore(name), Serdes.Integer(),
Serdes.Integer());
+ }
+
+ private static StoreBuilder<?> buildInMemoryWindowStore(final String
name) {
+ return Stores.windowStoreBuilder(
+ Stores.inMemoryWindowStore(name, WINDOW_RETENTION,
WINDOW_SIZE, false),
+ Serdes.Integer(),
+ Serdes.Integer());
+ }
+
+ private static StoreBuilder<?> buildPersistentWindowStore(final String
name) {
+ return Stores.windowStoreBuilder(
+ Stores.persistentWindowStore(name, WINDOW_RETENTION,
WINDOW_SIZE, false),
+ Serdes.Integer(),
+ Serdes.Integer());
+ }
+
+ private static StoreBuilder<?> buildInMemorySessionStore(final String
name) {
+ return Stores.sessionStoreBuilder(
+ Stores.inMemorySessionStore(name, SESSION_RETENTION),
Serdes.Integer(), Serdes.Integer());
+ }
+
+ private static StoreBuilder<?> buildPersistentSessionStore(final
String name) {
+ return Stores.sessionStoreBuilder(
+ Stores.persistentSessionStore(name, SESSION_RETENTION),
Serdes.Integer(), Serdes.Integer());
+ }
+
+ private static void stageKvWrite(final ProcessorContext<Void, Void>
ctx,
+ final Record<Integer, Integer> record,
+ final String storeName) {
+ final KeyValueStore<Integer, Integer> store =
ctx.getStateStore(storeName);
+ store.put(record.key(), record.value());
+ }
+
+ private static void stageWindowWrite(final ProcessorContext<Void,
Void> ctx,
+ final Record<Integer, Integer>
record,
+ final String storeName) {
+ final WindowStore<Integer, Integer> store =
ctx.getStateStore(storeName);
+ store.put(record.key(), record.value(),
windowStartFor(record.timestamp()));
+ }
+
+ private static void stageSessionWrite(final ProcessorContext<Void,
Void> ctx,
+ final Record<Integer, Integer>
record,
+ final String storeName) {
+ final SessionStore<Integer, Integer> store =
ctx.getStateStore(storeName);
+ store.put(new Windowed<>(record.key(), new
SessionWindow(record.timestamp(), record.timestamp())),
+ record.value());
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Integer readKv(final Object composite, final
Optional<IsolationLevel> override) {
+ ReadOnlyKeyValueStore<Integer, Integer> view =
(ReadOnlyKeyValueStore<Integer, Integer>) composite;
+ if (override.isPresent()) {
+ view = view.readOnly(override.get());
+ }
+ return view.get(TEST_KEY);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Integer readWindow(final Object composite, final
Optional<IsolationLevel> override) {
+ ReadOnlyWindowStore<Integer, Integer> view =
(ReadOnlyWindowStore<Integer, Integer>) composite;
+ if (override.isPresent()) {
+ view = view.readOnly(override.get());
+ }
+ final Instant t =
Instant.ofEpochMilli(windowStartFor(TEST_TIMESTAMP));
+ try (WindowStoreIterator<Integer> it = view.fetch(TEST_KEY, t, t))
{
+ return it.hasNext() ? it.next().value : null;
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Integer readSession(final Object composite, final
Optional<IsolationLevel> override) {
+ ReadOnlySessionStore<Integer, Integer> view =
(ReadOnlySessionStore<Integer, Integer>) composite;
+ if (override.isPresent()) {
+ view = view.readOnly(override.get());
+ }
+ try (KeyValueIterator<Windowed<Integer>, Integer> it =
view.fetch(TEST_KEY)) {
+ return it.hasNext() ? it.next().value : null;
+ }
+ }
+ }
+
+ @FunctionalInterface
+ interface WriteStep {
+ void stage(ProcessorContext<Void, Void> ctx, Record<Integer, Integer>
record, String storeName);
+ }
+
+ @FunctionalInterface
+ interface Reader {
+ Integer read(Object composite, Optional<IsolationLevel> override);
+ }
+
+ private KafkaStreams streams;
+ private StallGate gate;
+
+ @BeforeAll
+ public static void startCluster() throws Exception {
+ CLUSTER.start();
+ }
+
+ @AfterAll
+ public static void stopCluster() {
+ CLUSTER.stop();
+ }
+
+ @BeforeEach
+ public void resetFixtures() throws Exception {
+ CLUSTER.deleteTopic(INPUT_TOPIC);
+ CLUSTER.createTopic(INPUT_TOPIC, 1, 1);
+ gate = new StallGate();
+ }
+
+ @AfterEach
+ public void stopStreams() {
+ if (streams != null) {
+ streams.close(Duration.ofSeconds(30));
+ streams.cleanUp();
+ }
+ }
+
+ static Stream<Arguments> cases() {
+ return Stream.of(Shape.values())
+ .flatMap(shape -> Stream.of(
+ Arguments.of(shape, IsolationLevel.READ_UNCOMMITTED),
+ Arguments.of(shape, IsolationLevel.READ_COMMITTED)
+ ));
+ }
+
+ /**
+ * Two things to prove at each configured default level:
+ * <ol>
+ * <li>A bare composite ({@code streams.store(...)}) resolves the level
from the configured default
+ * — staged writes visible iff {@code READ_UNCOMMITTED}.</li>
+ * <li>Calling {@code composite.readOnly(otherLevel)} on the returned
view routes subsequent reads at
+ * that level instead — the per-query escape hatch.</li>
+ * </ol>
+ *
+ * <p>After releasing the stall, the test also waits for the next Streams
commit interval to fire
+ * and verifies that the committed value becomes visible through {@code
READ_COMMITTED} — proving
+ * that transactional state stores are flushed on the normal commit cycle
in EOS mode.
+ */
+ @ParameterizedTest(name = "{0} default={1}")
+ @MethodSource("cases")
+ public void shouldHonourConfiguredDefaultAndPerQueryOverride(final Shape
shape,
+ final
IsolationLevel configured) throws Exception {
+ startPipeline(shape, configured);
+
+ // Drive a single record through: the processor writes it, then parks
in the stall gate before the
+ // StreamThread can reach its next commit point. Writes are now live
in the transaction buffer.
+ sendOne(CLUSTER.bootstrapServers(), INPUT_TOPIC, TEST_KEY, TEST_VALUE,
TEST_TIMESTAMP);
+ gate.awaitStalled();
+
+ // The bare composite resolves the level from the configured default.
Explicit per-query overrides
+ // route reads at the requested level regardless of the default. These
three assertions together
+ // also prove the processor ran and staged the write: only the
READ_UNCOMMITTED view can see a
+ // value that hasn't flushed yet.
+ assertPreCommitVisibility(shape, Optional.empty(),
configured);
+ assertPreCommitVisibility(shape,
Optional.of(IsolationLevel.READ_UNCOMMITTED), IsolationLevel.READ_UNCOMMITTED);
+ assertPreCommitVisibility(shape,
Optional.of(IsolationLevel.READ_COMMITTED), IsolationLevel.READ_COMMITTED);
+
+ // Release the stall. On the next commit.interval.ms tick, postCommit
→ maybeCheckpoint →
+ // stateMgr.commit flushes the transaction buffer into the base store;
both isolation levels
+ // must converge on the now-durable value.
+ gate.release();
+ gate.awaitExit();
+ waitForCondition(
+ () -> Integer.valueOf(TEST_VALUE).equals(readValue(shape,
Optional.of(IsolationLevel.READ_COMMITTED))),
+ COMMIT_WAIT.toMillis(),
+ "IQv1 READ_COMMITTED view never observed the committed value for "
+ shape + "; commit did not propagate"
+ );
+ assertEquals(Integer.valueOf(TEST_VALUE), readValue(shape,
Optional.of(IsolationLevel.READ_UNCOMMITTED)));
+ }
+
+ private void assertPreCommitVisibility(final Shape shape,
+ final Optional<IsolationLevel>
override,
+ final IsolationLevel effective)
throws Exception {
+ final Integer observed = readValue(shape, override);
+ if (effective == IsolationLevel.READ_UNCOMMITTED) {
+ assertEquals(Integer.valueOf(TEST_VALUE), observed,
+ () -> "Expected " + shape + " override=" + override + "
effective=" + effective
+ + " to expose the staged value, saw " + observed);
+ } else {
+ assertNull(observed,
+ () -> "Expected " + shape + " override=" + override + "
effective=" + effective
+ + " to hide the staged value, saw " + observed);
+ }
+ }
+
+ private Integer readValue(final Shape shape, final
Optional<IsolationLevel> override) throws Exception {
+ final Object composite = IntegrationTestUtils.getStore(
+ streams,
+ StoreQueryParameters.fromNameAndType(shape.storeName,
shape.queryableType)
+ );
+ return shape.reader.read(composite, override);
+ }
+
+ private void startPipeline(final Shape shape, final IsolationLevel
configured) throws Exception {
+ final StreamsBuilder builder = new StreamsBuilder();
+ builder.addStateStore(shape.store());
+
+ final StallGate runtimeGate = gate;
+ final Shape runtimeShape = shape;
+ builder.<Integer, Integer>stream(INPUT_TOPIC)
+ .process(() -> new Processor<Integer, Integer, Void, Void>() {
+ private ProcessorContext<Void, Void> ctx;
+
+ @Override
+ public void init(final ProcessorContext<Void, Void> context) {
+ this.ctx = context;
+ }
+
+ @Override
+ public void process(final Record<Integer, Integer> record) {
+ runtimeShape.writeStep.stage(ctx, record,
runtimeShape.storeName);
+ runtimeGate.spinHere();
+ }
+ }, shape.storeName);
+
+ final Properties config = baseStreamsConfig();
+ config.put(StreamsConfig.APPLICATION_ID_CONFIG, "iq-iso-v1-" +
APP_COUNTER.incrementAndGet());
+ config.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
CLUSTER.bootstrapServers());
+ config.put(StreamsConfig.STATE_DIR_CONFIG,
TestUtils.tempDirectory().getAbsolutePath());
+
config.put(StreamsConfig.DEFAULT_INTERACTIVE_QUERY_ISOLATION_LEVEL_CONFIG,
configured.name());
+
+ streams = new KafkaStreams(builder.build(), config);
+ streams.cleanUp();
+ startApplicationAndWaitUntilRunning(streams);
+ }
+}
diff --git
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IsolationLevelIntegrationFixtures.java
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IsolationLevelIntegrationFixtures.java
new file mode 100644
index 00000000000..25e04c102e0
--- /dev/null
+++
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IsolationLevelIntegrationFixtures.java
@@ -0,0 +1,154 @@
+/*
+ * 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.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.clients.producer.ProducerConfig;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.serialization.IntegerSerializer;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.StreamsConfig;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.fail;
+
+/**
+ * Shared plumbing for the IQv1 and IQv2 isolation-level integration tests.
+ *
+ * <p>Both tests need the same thing: start a Streams app with {@code
enable.transactional.statestores=true}
+ * under EOS, drive a single record through a processor that writes to a state
store and then sits in a
+ * stall loop, and — while the processor is stalled — interrogate the store
through both
+ * {@code READ_UNCOMMITTED} and {@code READ_COMMITTED} views. The stall keeps
the StreamThread out of its
+ * consumer loop, which prevents any commit from firing; so staged writes
remain in the transaction buffer
+ * and {@code READ_COMMITTED} must not see them until the stall is released.
+ *
+ * <p>The gate itself is two latches plus a boolean:
+ * <ul>
+ * <li>{@link StallGate#stalled}: counted down by the processor the moment
it enters the stall loop,
+ * so the test knows the write has happened and the commit has not.</li>
+ * <li>{@link StallGate#released}: the processor spins on this flag until
the test flips it.</li>
+ * <li>{@link StallGate#exited}: counted down by the processor on the way
out, so the test can wait for
+ * the stream thread to return to its consumer loop (and thereby allow a
commit to occur).</li>
+ * </ul>
+ */
+final class IsolationLevelIntegrationFixtures {
+
+ private IsolationLevelIntegrationFixtures() {
+ }
+
+ /** Keys & values are small integers throughout. The sentinel key is
distinct from any test payload. */
+ static final int SENTINEL_KEY = -1;
+ static final int TEST_KEY = 7;
+ static final int TEST_VALUE = 42;
+ static final long TEST_TIMESTAMP = 1_700_000_000_000L;
+
+ /** Coordination object shared between the processor and the test body. */
+ static final class StallGate {
+ final CountDownLatch stalled = new CountDownLatch(1);
+ final AtomicBoolean released = new AtomicBoolean(false);
+ final CountDownLatch exited = new CountDownLatch(1);
+
+ void awaitStalled() throws InterruptedException {
+ if (!stalled.await(60, TimeUnit.SECONDS)) {
+ fail("Processor never entered stall");
+ }
+ }
+
+ void release() {
+ released.set(true);
+ }
+
+ void awaitExit() throws InterruptedException {
+ if (!exited.await(60, TimeUnit.SECONDS)) {
+ fail("Processor never left stall");
+ }
+ }
+
+ /** Run from inside the processor: signal stalled, spin until
released, signal exited. */
+ void spinHere() {
+ stalled.countDown();
+ try {
+ while (!released.get()) {
+ Thread.sleep(50L);
+ }
+ } catch (final InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new RuntimeException(e);
+ } finally {
+ exited.countDown();
+ }
+ }
+ }
+
+ /**
+ * Base Streams configuration needed to observe isolation-level
differences:
+ * EOS + transactional statestores + no cache + a small but non-zero
commit interval so that once the
+ * stall is released, a commit fires promptly. Caller layers on
application id, bootstrap servers, state
+ * dir, and the isolation-level default under test.
+ */
+ static Properties baseStreamsConfig() {
+ final Properties p = new Properties();
+ p.put(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
StreamsConfig.EXACTLY_ONCE_V2);
+ p.put(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, "true");
+ p.put(StreamsConfig.STATESTORE_CACHE_MAX_BYTES_CONFIG, 0);
+ p.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 1000L);
+ p.put(StreamsConfig.NUM_STREAM_THREADS_CONFIG, 1);
+ p.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG,
Serdes.IntegerSerde.class);
+ p.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG,
Serdes.IntegerSerde.class);
+ return p;
+ }
+
+ /** Synchronous single-record producer — returns once the record is acked
by the broker. */
+ static void sendOne(final String bootstrap,
+ final String topic,
+ final int key,
+ final int value,
+ final long timestamp) throws Exception {
+ final Properties props = new Properties();
+ props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
+ props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
IntegerSerializer.class);
+ props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
IntegerSerializer.class);
+ try (final Producer<Integer, Integer> producer = new
KafkaProducer<>(props)) {
+ producer.send(new ProducerRecord<>(topic, 0, timestamp, key,
value)).get(30, TimeUnit.SECONDS);
+ }
+ }
+
+ /** Polling deadline used by visibility assertions that wait on a commit.
*/
+ static final Duration COMMIT_WAIT = Duration.ofSeconds(30);
+
+ /**
+ * Broker-side overrides that let the embedded 1-broker cluster support
EOS transactions — the default
+ * transaction state-log replication/ISR requirements need 3 brokers,
which is more than we need.
+ */
+ static Properties singleBrokerEosOverrides() {
+ final Map<String, String> overrides = new HashMap<>();
+ overrides.put("auto.create.topics.enable", "true");
+ overrides.put("transaction.state.log.replication.factor", "1");
+ overrides.put("transaction.state.log.min.isr", "1");
+ final Properties props = new Properties();
+ props.putAll(overrides);
+ return props;
+ }
+}