diff --git a/checkstyle/suppressions.xml b/checkstyle/suppressions.xml index c65cd675a9ef3..a48bb64b15350 100644 --- a/checkstyle/suppressions.xml +++ b/checkstyle/suppressions.xml @@ -234,7 +234,7 @@ + files="(RecordCollectorTest|StreamsPartitionAssignorTest|StreamThreadTest|StreamTaskTest|TaskManagerTest|TopologyTestDriverTest|KafkaStreamsTest|EosIntegrationTest|RestoreIntegrationTest).java"/> diff --git a/docs/streams/developer-guide/processor-api.html b/docs/streams/developer-guide/processor-api.html index ccb03ce7b50c1..e25ecd601ac5c 100644 --- a/docs/streams/developer-guide/processor-api.html +++ b/docs/streams/developer-guide/processor-api.html @@ -51,6 +51,7 @@
  • Enable or Disable Fault Tolerance of State Stores (Store Changelogs)
  • Timestamped State Stores
  • Versioned Key-Value State Stores
  • +
  • Readonly State Stores
  • Implementing Custom State Stores
  • @@ -466,6 +467,18 @@

    stores to rebuild state from changelog.

    +
    +

    ReadOnly State Stores

    +

    A read-only state store materialized the data from its input topic. It also uses the input topic + for fault-tolerance, and thus does not have an additional changelog topic (the input topic is + re-used as changelog). Thus, the input topic should be configured with log compaction. + Note that no other processor should modify the content of the state store, and the only writer + should be the associated "state update processor"; other processors may read the content of the + read-only store.

    + +

    note: beware of the partitioning requirements when using read-only state stores for lookups during + processing. You might want to make sure the original changelog topic is co-partitioned with the processors + reading the read-only statestore.

    Implementing Custom State Stores

    diff --git a/docs/streams/upgrade-guide.html b/docs/streams/upgrade-guide.html index 14544077403cd..0f819cb384f92 100644 --- a/docs/streams/upgrade-guide.html +++ b/docs/streams/upgrade-guide.html @@ -133,6 +133,14 @@

    < More details about the new config StreamsConfig#TOPOLOGY_OPTIMIZATION can be found in KIP-295.

    +

    Streams API changes in 3.8.0

    +

    + The Processor API now support so-called read-only state stores, added via + KIP-813. + These stores don't have a dedicated changelog topic, but use their source topic for fault-tolerance, + simlar to KTables with source-topic optimization enabled. +

    +

    Streams API changes in 3.7.0

    We added a new method to KafkaStreams, namely KafkaStreams#setStandbyUpdateListener() in diff --git a/streams/src/main/java/org/apache/kafka/streams/Topology.java b/streams/src/main/java/org/apache/kafka/streams/Topology.java index d9c810aa42599..d1f0d1eb8bc8e 100644 --- a/streams/src/main/java/org/apache/kafka/streams/Topology.java +++ b/streams/src/main/java/org/apache/kafka/streams/Topology.java @@ -738,6 +738,88 @@ public synchronized Topology addStateStore(final StoreBuilder storeBuilder, return this; } + /** + * Adds a read-only {@link StateStore} to the topology. + *

    + * A read-only {@link StateStore} does not create a dedicated changelog topic but uses it's input topic as + * changelog; thus, the used topic should be configured with log compaction. + *

    + * The auto.offset.reset property will be set to earliest for this topic. + *

    + * The provided {@link ProcessorSupplier} will be used to create a processor for all messages received + * from the given topic. This processor should contain logic to keep the {@link StateStore} up-to-date. + * + * @param storeBuilder user defined store builder + * @param sourceName name of the {@link SourceNode} that will be automatically added + * @param timestampExtractor the stateless timestamp extractor used for this source, + * if not specified the default extractor defined in the configs will be used + * @param keyDeserializer the {@link Deserializer} to deserialize keys with + * @param valueDeserializer the {@link Deserializer} to deserialize values with + * @param topic the topic to source the data from + * @param processorName the name of the {@link ProcessorSupplier} + * @param stateUpdateSupplier the instance of {@link ProcessorSupplier} + * @return itself + * @throws TopologyException if the processor of state is already registered + */ + public synchronized Topology addReadOnlyStateStore(final StoreBuilder storeBuilder, + final String sourceName, + final TimestampExtractor timestampExtractor, + final Deserializer keyDeserializer, + final Deserializer valueDeserializer, + final String topic, + final String processorName, + final ProcessorSupplier stateUpdateSupplier) { + storeBuilder.withLoggingDisabled(); + + internalTopologyBuilder.addSource(AutoOffsetReset.EARLIEST, sourceName, timestampExtractor, keyDeserializer, valueDeserializer, topic); + internalTopologyBuilder.addProcessor(processorName, stateUpdateSupplier, sourceName); + internalTopologyBuilder.addStateStore(storeBuilder, processorName); + internalTopologyBuilder.connectSourceStoreAndTopic(storeBuilder.name(), topic); + + return this; + } + + /** + * Adds a read-only {@link StateStore} to the topology. + *

    + * A read-only {@link StateStore} does not create a dedicated changelog topic but uses it's input topic as + * changelog; thus, the used topic should be configured with log compaction. + *

    + * The auto.offset.reset property will be set to earliest for this topic. + *

    + * The provided {@link ProcessorSupplier} will be used to create a processor for all messages received + * from the given topic. This processor should contain logic to keep the {@link StateStore} up-to-date. + * The default {@link TimestampExtractor} as specified in the {@link StreamsConfig config} is used. + * + * @param storeBuilder user defined store builder + * @param sourceName name of the {@link SourceNode} that will be automatically added + * @param keyDeserializer the {@link Deserializer} to deserialize keys with + * @param valueDeserializer the {@link Deserializer} to deserialize values with + * @param topic the topic to source the data from + * @param processorName the name of the {@link ProcessorSupplier} + * @param stateUpdateSupplier the instance of {@link ProcessorSupplier} + * @return itself + * @throws TopologyException if the processor of state is already registered + */ + public synchronized Topology addReadOnlyStateStore(final StoreBuilder storeBuilder, + final String sourceName, + final Deserializer keyDeserializer, + final Deserializer valueDeserializer, + final String topic, + final String processorName, + final ProcessorSupplier stateUpdateSupplier) { + return addReadOnlyStateStore( + storeBuilder, + sourceName, + null, + keyDeserializer, + valueDeserializer, + topic, + processorName, + stateUpdateSupplier + ); + } + /** * Adds a global {@link StateStore} to the topology. * The {@link StateStore} sources its data from all partitions of the provided input topic. diff --git a/streams/src/test/java/org/apache/kafka/streams/TopologyTest.java b/streams/src/test/java/org/apache/kafka/streams/TopologyTest.java index 5fdf5c220cfa2..ceecbc3bc2178 100644 --- a/streams/src/test/java/org/apache/kafka/streams/TopologyTest.java +++ b/streams/src/test/java/org/apache/kafka/streams/TopologyTest.java @@ -38,6 +38,7 @@ import org.apache.kafka.streams.processor.internals.InternalTopologyBuilder; import org.apache.kafka.streams.processor.internals.InternalTopologyBuilder.SubtopologyDescription; import org.apache.kafka.streams.processor.internals.ProcessorTopology; +import org.apache.kafka.streams.processor.internals.StoreFactory; import org.apache.kafka.streams.state.KeyValueStore; import org.apache.kafka.streams.state.SessionStore; import org.apache.kafka.streams.state.StoreBuilder; @@ -58,6 +59,7 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.internal.util.collections.Sets; import java.time.Duration; import java.util.Arrays; @@ -337,7 +339,7 @@ public void shouldNotAllowToAddStoreWithSameNameAndDifferentInstance() { mockStoreBuilder(); topology.addStateStore(storeBuilder); - final StoreBuilder otherStoreBuilder = mock(StoreBuilder.class); + final StoreBuilder otherStoreBuilder = mock(StoreBuilder.class); when(otherStoreBuilder.name()).thenReturn("store"); when(otherStoreBuilder.logConfig()).thenReturn(Collections.emptyMap()); when(otherStoreBuilder.loggingEnabled()).thenReturn(false); @@ -2313,7 +2315,7 @@ private TopologyDescription.Sink addSink(final String sinkName, topology.addSink(sinkName, sinkTopic, null, null, null, parentNames); final TopologyDescription.Sink expectedSinkNode = - new InternalTopologyBuilder.Sink(sinkName, sinkTopic); + new InternalTopologyBuilder.Sink<>(sinkName, sinkTopic); for (final TopologyDescription.Node parent : parents) { ((InternalTopologyBuilder.AbstractNode) parent).addSuccessor(expectedSinkNode); @@ -2351,6 +2353,63 @@ private void addGlobalStoreToTopologyAndExpectedDescription(final String globalS expectedDescription.addGlobalStore(expectedGlobalStore); } + @Test + public void readOnlyStateStoresShouldHaveTheirOwnSubTopology() { + final String sourceName = "source"; + final String storeName = "store"; + final String topicName = "topic"; + final String processorName = "processor"; + + final KeyValueStoreBuilder storeBuilder = mock(KeyValueStoreBuilder.class); + when(storeBuilder.name()).thenReturn(storeName); + topology.addReadOnlyStateStore( + storeBuilder, + sourceName, + null, + null, + null, + topicName, + processorName, + new MockProcessorSupplier<>()); + + final TopologyDescription.Source expectedSource = new InternalTopologyBuilder.Source(sourceName, Sets.newSet(topicName), null); + final TopologyDescription.Processor expectedProcessor = new InternalTopologyBuilder.Processor(processorName, Sets.newSet(storeName)); + + ((InternalTopologyBuilder.AbstractNode) expectedSource).addSuccessor(expectedProcessor); + ((InternalTopologyBuilder.AbstractNode) expectedProcessor).addPredecessor(expectedSource); + + final Set allNodes = new HashSet<>(); + allNodes.add(expectedSource); + allNodes.add(expectedProcessor); + expectedDescription.addSubtopology(new SubtopologyDescription(0, allNodes)); + + assertThat(topology.describe(), equalTo(expectedDescription)); + assertThat(topology.describe().hashCode(), equalTo(expectedDescription.hashCode())); + } + + @Test + public void readOnlyStateStoresShouldNotLog() { + final String sourceName = "source"; + final String storeName = "store"; + final String topicName = "topic"; + final String processorName = "processor"; + + final KeyValueStoreBuilder storeBuilder = mock(KeyValueStoreBuilder.class); + when(storeBuilder.name()).thenReturn(storeName); + topology.addReadOnlyStateStore( + storeBuilder, + sourceName, + null, + null, + null, + topicName, + processorName, + new MockProcessorSupplier<>()); + + final StoreFactory stateStoreFactory = topology.internalTopologyBuilder.stateStores().get(storeName); + assertThat(stateStoreFactory.loggingEnabled(), equalTo(false)); + } + private TopologyConfig overrideDefaultStore(final String defaultStore) { final Properties topologyOverrides = new Properties(); // change default store as in-memory diff --git a/streams/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java b/streams/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java index d79631f8f7a0c..7989de0e76bb7 100644 --- a/streams/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java +++ b/streams/src/test/java/org/apache/kafka/streams/integration/EosIntegrationTest.java @@ -123,8 +123,8 @@ public class EosIntegrationTest { public Timeout globalTimeout = Timeout.seconds(600); private static final Logger LOG = LoggerFactory.getLogger(EosIntegrationTest.class); private static final int NUM_BROKERS = 3; - private static final int MAX_POLL_INTERVAL_MS = 5 * 1000; - private static final int MAX_WAIT_TIME_MS = 60 * 1000; + private static final int MAX_POLL_INTERVAL_MS = 30_1000; + private static final int MAX_WAIT_TIME_MS = 120_1000; public static final EmbeddedKafkaCluster CLUSTER = new EmbeddedKafkaCluster( NUM_BROKERS, @@ -403,7 +403,7 @@ public void shouldNotViolateEosIfOneTaskFails() throws Exception { // -> the failure only kills one thread // after fail over, we should read 40 committed records (even if 50 record got written) - try (final KafkaStreams streams = getKafkaStreams("dummy", false, "appDir", 2, eosConfig, MAX_POLL_INTERVAL_MS)) { + try (final KafkaStreams streams = getKafkaStreams("dummy", false, "appDir", 2, eosConfig)) { startApplicationAndWaitUntilRunning(streams); final List> committedDataBeforeFailure = prepareData(0L, 10L, 0L, 1L); @@ -511,7 +511,7 @@ public void shouldNotViolateEosIfOneTaskFailsWithState() throws Exception { // We need more processing time under "with state" situation, so increasing the max.poll.interval.ms // to avoid unexpected rebalance during test, which will cause unexpected fail over triggered - try (final KafkaStreams streams = getKafkaStreams("dummy", true, "appDir", 2, eosConfig, 3 * MAX_POLL_INTERVAL_MS)) { + try (final KafkaStreams streams = getKafkaStreams("dummy", true, "appDir", 2, eosConfig)) { startApplicationAndWaitUntilRunning(streams); final List> committedDataBeforeFailure = prepareData(0L, 10L, 0L, 1L); @@ -624,12 +624,12 @@ public void shouldNotViolateEosIfOneTaskGetsFencedUsingIsolatedAppInstances() th // -> the stall only affects one thread and should trigger a rebalance // after rebalancing, we should read 40 committed records (even if 50 record got written) // - // afterwards, the "stalling" thread resumes, and another rebalance should get triggered + // afterward, the "stalling" thread resumes, and another rebalance should get triggered // we write the remaining 20 records and verify to read 60 result records try ( - final KafkaStreams streams1 = getKafkaStreams("streams1", false, "appDir1", 1, eosConfig, MAX_POLL_INTERVAL_MS); - final KafkaStreams streams2 = getKafkaStreams("streams2", false, "appDir2", 1, eosConfig, MAX_POLL_INTERVAL_MS) + final KafkaStreams streams1 = getKafkaStreams("streams1", false, "appDir1", 1, eosConfig); + final KafkaStreams streams2 = getKafkaStreams("streams2", false, "appDir2", 1, eosConfig) ) { startApplicationAndWaitUntilRunning(streams1); startApplicationAndWaitUntilRunning(streams2); @@ -778,7 +778,7 @@ public void shouldWriteLatestOffsetsToCheckpointOnShutdown() throws Exception { final List> writtenData = prepareData(0L, 10, 0L, 1L); final List> expectedResult = computeExpectedResult(writtenData); - try (final KafkaStreams streams = getKafkaStreams("streams", true, "appDir", 1, eosConfig, MAX_POLL_INTERVAL_MS)) { + try (final KafkaStreams streams = getKafkaStreams("streams", true, "appDir", 1, eosConfig)) { writeInputData(writtenData); startApplicationAndWaitUntilRunning(streams); @@ -1004,8 +1004,7 @@ private KafkaStreams getKafkaStreams(final String dummyHostName, final boolean withState, final String appDir, final int numberOfStreamsThreads, - final String eosConfig, - final int maxPollIntervalMs) { + final String eosConfig) { commitRequested = new AtomicInteger(0); errorInjected = new AtomicBoolean(false); stallInjected = new AtomicBoolean(false); @@ -1112,9 +1111,8 @@ public void close() { } properties.put(StreamsConfig.producerPrefix(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG), (int) commitIntervalMs); properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.METADATA_MAX_AGE_CONFIG), "1000"); properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG), "earliest"); - properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.REQUEST_TIMEOUT_MS_CONFIG), maxPollIntervalMs); - properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG), maxPollIntervalMs - 1); - properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG), maxPollIntervalMs); + properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG), MAX_POLL_INTERVAL_MS - 1); + properties.put(StreamsConfig.consumerPrefix(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG), MAX_POLL_INTERVAL_MS); properties.put(StreamsConfig.STATESTORE_CACHE_MAX_BYTES_CONFIG, 0); properties.put(StreamsConfig.STATE_DIR_CONFIG, stateTmpDir + appDir); properties.put(StreamsConfig.APPLICATION_SERVER_CONFIG, dummyHostName + ":2142"); diff --git a/streams/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java b/streams/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java index 812da30074775..b41e0c3d29f2f 100644 --- a/streams/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java +++ b/streams/src/test/java/org/apache/kafka/streams/integration/RestoreIntegrationTest.java @@ -28,6 +28,7 @@ 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.serialization.StringDeserializer; import org.apache.kafka.common.utils.Bytes; import org.apache.kafka.common.utils.MockTime; import org.apache.kafka.common.utils.Utils; @@ -155,8 +156,8 @@ private Properties props(final Properties extraProperties) { streamsConfiguration.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, CLUSTER.bootstrapServers()); streamsConfiguration.put(StreamsConfig.STATESTORE_CACHE_MAX_BYTES_CONFIG, 0); streamsConfiguration.put(StreamsConfig.STATE_DIR_CONFIG, TestUtils.tempDirectory(appId).getPath()); - streamsConfiguration.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.Integer().getClass()); - streamsConfiguration.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.Integer().getClass()); + streamsConfiguration.put(StreamsConfig.DEFAULT_KEY_SERDE_CLASS_CONFIG, Serdes.IntegerSerde.class); + streamsConfiguration.put(StreamsConfig.DEFAULT_VALUE_SERDE_CLASS_CONFIG, Serdes.IntegerSerde.class); streamsConfiguration.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 1000L); streamsConfiguration.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); streamsConfiguration.putAll(extraProperties); @@ -196,8 +197,8 @@ public void shouldRestoreNullRecord() throws Exception { final Properties streamsConfiguration = StreamsTestUtils.getStreamsConfig( applicationId, CLUSTER.bootstrapServers(), - Serdes.Integer().getClass().getName(), - Serdes.ByteArray().getClass().getName(), + Serdes.IntegerSerde.class.getName(), + Serdes.BytesSerde.class.getName(), props); CLUSTER.createTopics(inputTopic); @@ -249,7 +250,63 @@ public void shouldRestoreNullRecord() throws Exception { @ParameterizedTest @MethodSource("parameters") - public void shouldRestoreStateFromSourceTopic(final boolean stateUpdaterEnabled) throws Exception { + public void shouldRestoreStateFromSourceTopicForReadOnlyStore(final boolean stateUpdaterEnabled) throws Exception { + final AtomicInteger numReceived = new AtomicInteger(0); + final Topology topology = new Topology(); + + final Properties props = props(stateUpdaterEnabled); + + // restoring from 1000 to 4000 (committed), and then process from 4000 to 5000 on each of the two partitions + final int offsetLimitDelta = 1000; + final int offsetCheckpointed = 1000; + createStateForRestoration(inputStream, 0); + setCommittedOffset(inputStream, offsetLimitDelta); + + final StateDirectory stateDirectory = new StateDirectory(new StreamsConfig(props), new MockTime(), true, false); + // note here the checkpointed offset is the last processed record's offset, so without control message we should write this offset - 1 + new OffsetCheckpoint(new File(stateDirectory.getOrCreateDirectoryForTask(new TaskId(0, 0)), ".checkpoint")) + .write(Collections.singletonMap(new TopicPartition(inputStream, 0), (long) offsetCheckpointed - 1)); + new OffsetCheckpoint(new File(stateDirectory.getOrCreateDirectoryForTask(new TaskId(0, 1)), ".checkpoint")) + .write(Collections.singletonMap(new TopicPartition(inputStream, 1), (long) offsetCheckpointed - 1)); + + final CountDownLatch startupLatch = new CountDownLatch(1); + final CountDownLatch shutdownLatch = new CountDownLatch(1); + + topology.addReadOnlyStateStore( + Stores.keyValueStoreBuilder( + Stores.persistentKeyValueStore("store"), + new Serdes.IntegerSerde(), + new Serdes.StringSerde() + ), + "readOnlySource", + new IntegerDeserializer(), + new StringDeserializer(), + inputStream, + "readOnlyProcessor", + () -> new ReadOnlyStoreProcessor(numReceived, offsetLimitDelta, shutdownLatch) + ); + + kafkaStreams = new KafkaStreams(topology, props); + kafkaStreams.setStateListener((newState, oldState) -> { + if (newState == KafkaStreams.State.RUNNING && oldState == KafkaStreams.State.REBALANCING) { + startupLatch.countDown(); + } + }); + + final AtomicLong restored = new AtomicLong(0); + kafkaStreams.setGlobalStateRestoreListener(new TrackingStateRestoreListener(restored)); + kafkaStreams.start(); + + assertTrue(startupLatch.await(30, TimeUnit.SECONDS)); + assertThat(restored.get(), equalTo((long) numberOfKeys - offsetLimitDelta * 2 - offsetCheckpointed * 2)); + + assertTrue(shutdownLatch.await(30, TimeUnit.SECONDS)); + assertThat(numReceived.get(), equalTo(offsetLimitDelta * 2)); + } + + @ParameterizedTest + @MethodSource("parameters") + public void shouldRestoreStateFromSourceTopicForGlobalTable(final boolean stateUpdaterEnabled) throws Exception { final AtomicInteger numReceived = new AtomicInteger(0); final StreamsBuilder builder = new StreamsBuilder(); @@ -265,9 +322,9 @@ public void shouldRestoreStateFromSourceTopic(final boolean stateUpdaterEnabled) final StateDirectory stateDirectory = new StateDirectory(new StreamsConfig(props), new MockTime(), true, false); // note here the checkpointed offset is the last processed record's offset, so without control message we should write this offset - 1 new OffsetCheckpoint(new File(stateDirectory.getOrCreateDirectoryForTask(new TaskId(0, 0)), ".checkpoint")) - .write(Collections.singletonMap(new TopicPartition(inputStream, 0), (long) offsetCheckpointed - 1)); + .write(Collections.singletonMap(new TopicPartition(inputStream, 0), (long) offsetCheckpointed - 1)); new OffsetCheckpoint(new File(stateDirectory.getOrCreateDirectoryForTask(new TaskId(0, 1)), ".checkpoint")) - .write(Collections.singletonMap(new TopicPartition(inputStream, 1), (long) offsetCheckpointed - 1)); + .write(Collections.singletonMap(new TopicPartition(inputStream, 1), (long) offsetCheckpointed - 1)); final CountDownLatch startupLatch = new CountDownLatch(1); final CountDownLatch shutdownLatch = new CountDownLatch(1); @@ -288,22 +345,7 @@ public void shouldRestoreStateFromSourceTopic(final boolean stateUpdaterEnabled) }); final AtomicLong restored = new AtomicLong(0); - kafkaStreams.setGlobalStateRestoreListener(new StateRestoreListener() { - @Override - public void onRestoreStart(final TopicPartition topicPartition, final String storeName, final long startingOffset, final long endingOffset) { - - } - - @Override - public void onBatchRestored(final TopicPartition topicPartition, final String storeName, final long batchEndOffset, final long numRestored) { - - } - - @Override - public void onRestoreEnd(final TopicPartition topicPartition, final String storeName, final long totalRestored) { - restored.addAndGet(totalRestored); - } - }); + kafkaStreams.setGlobalStateRestoreListener(new TrackingStateRestoreListener(restored)); kafkaStreams.start(); assertTrue(startupLatch.await(30, TimeUnit.SECONDS)); @@ -332,9 +374,9 @@ public void shouldRestoreStateFromChangelogTopic(final boolean stateUpdaterEnabl final StateDirectory stateDirectory = new StateDirectory(new StreamsConfig(props), new MockTime(), true, false); // note here the checkpointed offset is the last processed record's offset, so without control message we should write this offset - 1 new OffsetCheckpoint(new File(stateDirectory.getOrCreateDirectoryForTask(new TaskId(0, 0)), ".checkpoint")) - .write(Collections.singletonMap(new TopicPartition(changelog, 0), (long) offsetCheckpointed - 1)); + .write(Collections.singletonMap(new TopicPartition(changelog, 0), (long) offsetCheckpointed - 1)); new OffsetCheckpoint(new File(stateDirectory.getOrCreateDirectoryForTask(new TaskId(0, 1)), ".checkpoint")) - .write(Collections.singletonMap(new TopicPartition(changelog, 1), (long) offsetCheckpointed - 1)); + .write(Collections.singletonMap(new TopicPartition(changelog, 1), (long) offsetCheckpointed - 1)); final CountDownLatch startupLatch = new CountDownLatch(1); final CountDownLatch shutdownLatch = new CountDownLatch(1); @@ -355,22 +397,7 @@ public void shouldRestoreStateFromChangelogTopic(final boolean stateUpdaterEnabl }); final AtomicLong restored = new AtomicLong(0); - kafkaStreams.setGlobalStateRestoreListener(new StateRestoreListener() { - @Override - public void onRestoreStart(final TopicPartition topicPartition, final String storeName, final long startingOffset, final long endingOffset) { - - } - - @Override - public void onBatchRestored(final TopicPartition topicPartition, final String storeName, final long batchEndOffset, final long numRestored) { - - } - - @Override - public void onRestoreEnd(final TopicPartition topicPartition, final String storeName, final long totalRestored) { - restored.addAndGet(totalRestored); - } - }); + kafkaStreams.setGlobalStateRestoreListener(new TrackingStateRestoreListener(restored)); kafkaStreams.start(); assertTrue(startupLatch.await(30, TimeUnit.SECONDS)); @@ -386,10 +413,12 @@ public void shouldSuccessfullyStartWhenLoggingDisabled(final boolean stateUpdate final StreamsBuilder builder = new StreamsBuilder(); final KStream stream = builder.stream(inputStream); - stream.groupByKey() - .reduce( - (value1, value2) -> value1 + value2, - Materialized.>as("reduce-store").withLoggingDisabled()); + stream + .groupByKey() + .reduce( + Integer::sum, + Materialized.>as("reduce-store").withLoggingDisabled() + ); final CountDownLatch startupLatch = new CountDownLatch(1); kafkaStreams = new KafkaStreams(builder.build(), props(stateUpdaterEnabled)); @@ -821,4 +850,30 @@ private void waitForTransitionTo(final Set observed, final K () -> "Client did not transition to " + state + " on time. Observed transitions: " + observed ); } + + private static class ReadOnlyStoreProcessor implements Processor { + private final AtomicInteger numReceived; + private final int offsetLimitDelta; + private final CountDownLatch shutdownLatch; + KeyValueStore store; + + public ReadOnlyStoreProcessor(final AtomicInteger numReceived, final int offsetLimitDelta, final CountDownLatch shutdownLatch) { + this.numReceived = numReceived; + this.offsetLimitDelta = offsetLimitDelta; + this.shutdownLatch = shutdownLatch; + } + + @Override + public void init(final ProcessorContext context) { + store = context.getStateStore("store"); + } + + @Override + public void process(final Record record) { + store.put(record.key(), record.value()); + if (numReceived.incrementAndGet() == offsetLimitDelta * 2) { + shutdownLatch.countDown(); + } + } + } } \ No newline at end of file diff --git a/streams/src/test/java/org/apache/kafka/streams/integration/utils/IntegrationTestUtils.java b/streams/src/test/java/org/apache/kafka/streams/integration/utils/IntegrationTestUtils.java index 4f1d8d3d4266f..7c5734d7b96af 100644 --- a/streams/src/test/java/org/apache/kafka/streams/integration/utils/IntegrationTestUtils.java +++ b/streams/src/test/java/org/apache/kafka/streams/integration/utils/IntegrationTestUtils.java @@ -1518,6 +1518,15 @@ public static class TrackingStateRestoreListener implements StateRestoreListener public final Map changelogToStartOffset = new ConcurrentHashMap<>(); public final Map changelogToEndOffset = new ConcurrentHashMap<>(); public final Map changelogToTotalNumRestored = new ConcurrentHashMap<>(); + private final AtomicLong restored; + + public TrackingStateRestoreListener() { + restored = null; + } + + public TrackingStateRestoreListener(final AtomicLong restored) { + this.restored = restored; + } @Override public void onRestoreStart(final TopicPartition topicPartition, @@ -1541,6 +1550,9 @@ public void onBatchRestored(final TopicPartition topicPartition, public void onRestoreEnd(final TopicPartition topicPartition, final String storeName, final long totalRestored) { + if (restored != null) { + restored.addAndGet(totalRestored); + } } public long totalNumRestored() { diff --git a/streams/src/test/java/org/apache/kafka/streams/processor/ReadOnlyStoreTest.java b/streams/src/test/java/org/apache/kafka/streams/processor/ReadOnlyStoreTest.java new file mode 100644 index 0000000000000..a786c82bf25bd --- /dev/null +++ b/streams/src/test/java/org/apache/kafka/streams/processor/ReadOnlyStoreTest.java @@ -0,0 +1,132 @@ +/* + * 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.processor; + +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.serialization.StringDeserializer; +import org.apache.kafka.common.serialization.StringSerializer; +import org.apache.kafka.streams.KeyValue; +import org.apache.kafka.streams.TestInputTopic; +import org.apache.kafka.streams.TestOutputTopic; +import org.apache.kafka.streams.Topology; +import org.apache.kafka.streams.TopologyTestDriver; +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.Stores; +import org.junit.Test; + +import java.util.LinkedList; +import java.util.List; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class ReadOnlyStoreTest { + + @Test + public void shouldConnectProcessorAndWriteDataToReadOnlyStore() { + final Topology topology = new Topology(); + topology.addReadOnlyStateStore( + Stores.keyValueStoreBuilder( + Stores.inMemoryKeyValueStore("readOnlyStore"), + new Serdes.IntegerSerde(), + new Serdes.StringSerde() + ), + "readOnlySource", + new IntegerDeserializer(), + new StringDeserializer(), + "storeTopic", + "readOnlyProcessor", + () -> new Processor() { + KeyValueStore store; + + @Override + public void init(final ProcessorContext context) { + store = context.getStateStore("readOnlyStore"); + } + @Override + public void process(final Record record) { + store.put(record.key(), record.value()); + } + } + ); + + topology.addSource("source", new IntegerDeserializer(), new StringDeserializer(), "inputTopic"); + topology.addProcessor( + "processor", + () -> new Processor() { + ProcessorContext context; + KeyValueStore store; + + @Override + public void init(final ProcessorContext context) { + this.context = context; + store = context.getStateStore("readOnlyStore"); + } + + @Override + public void process(final Record record) { + context.forward(record.withValue( + record.value() + " -- " + store.get(record.key()) + )); + } + }, + "source" + ); + topology.connectProcessorAndStateStores("processor", "readOnlyStore"); + topology.addSink("sink", "outputTopic", new IntegerSerializer(), new StringSerializer(), "processor"); + + try (final TopologyTestDriver driver = new TopologyTestDriver(topology)) { + final TestInputTopic readOnlyStoreTopic = + driver.createInputTopic("storeTopic", new IntegerSerializer(), new StringSerializer()); + final TestInputTopic input = + driver.createInputTopic("inputTopic", new IntegerSerializer(), new StringSerializer()); + final TestOutputTopic output = + driver.createOutputTopic("outputTopic", new IntegerDeserializer(), new StringDeserializer()); + + readOnlyStoreTopic.pipeInput(1, "foo"); + readOnlyStoreTopic.pipeInput(2, "bar"); + + input.pipeInput(1, "bar"); + input.pipeInput(2, "foo"); + + final KeyValueStore store = driver.getKeyValueStore("readOnlyStore"); + + try (final KeyValueIterator it = store.all()) { + final List> storeContent = new LinkedList<>(); + it.forEachRemaining(storeContent::add); + + final List> expectedResult = new LinkedList<>(); + expectedResult.add(KeyValue.pair(1, "foo")); + expectedResult.add(KeyValue.pair(2, "bar")); + + assertThat(storeContent, equalTo(expectedResult)); + } + + final List> expectedResult = new LinkedList<>(); + expectedResult.add(KeyValue.pair(1, "bar -- foo")); + expectedResult.add(KeyValue.pair(2, "foo -- bar")); + + assertThat(output.readKeyValuesToList(), equalTo(expectedResult)); + } + } +} diff --git a/streams/src/test/java/org/apache/kafka/test/MockProcessor.java b/streams/src/test/java/org/apache/kafka/test/MockProcessor.java index 9766d1c0fe85c..8c1812ccf14a2 100644 --- a/streams/src/test/java/org/apache/kafka/test/MockProcessor.java +++ b/streams/src/test/java/org/apache/kafka/test/MockProcessor.java @@ -32,7 +32,6 @@ public class MockProcessor implements Processor { private final MockApiProcessor delegate; - public MockProcessor(final PunctuationType punctuationType, final long scheduleInterval) { delegate = new MockApiProcessor<>(punctuationType, scheduleInterval); @@ -43,12 +42,12 @@ public MockProcessor() { } @Override - public void init(ProcessorContext context) { + public void init(final ProcessorContext context) { delegate.init(context); } @Override - public void process(Record record) { + public void process(final Record record) { delegate.process(record); }