-
Notifications
You must be signed in to change notification settings - Fork 15.4k
KIP-557: Add Emit On Change Support #8254
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 15 commits
80de5bb
1aaaa01
ec46f57
2c19bf7
e3ffa93
a14725a
6d3c7ab
81c19bd
209d376
8fef99c
1d7c4ad
d062bfa
d0737f5
afdde96
35c16b1
9e7649b
21a7345
c4257e1
6ff8b65
860d41b
2871c6b
367ebaf
449efd9
9cd3726
cdad348
48cc4d9
de0fc6d
d396fd4
7d93107
07d40af
4779b27
ddbf2cf
197ddd2
527ba28
bae2860
bf5532f
d9aa12d
2a3d93d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,15 +21,19 @@ | |
| import org.apache.kafka.streams.processor.Processor; | ||
| import org.apache.kafka.streams.processor.ProcessorContext; | ||
| import org.apache.kafka.streams.processor.ProcessorSupplier; | ||
| import org.apache.kafka.streams.processor.StateStore; | ||
| import org.apache.kafka.streams.processor.internals.InternalProcessorContext; | ||
| import org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl; | ||
| import org.apache.kafka.streams.state.TimestampedKeyValueStore; | ||
| import org.apache.kafka.streams.state.ValueAndTimestamp; | ||
| import org.apache.kafka.streams.state.internals.MeteredTimestampedKeyValueStore; | ||
| import org.apache.kafka.streams.state.internals.WrappedStateStore; | ||
| import org.slf4j.Logger; | ||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| import java.util.Objects; | ||
|
|
||
| import static org.apache.kafka.streams.processor.internals.metrics.TaskMetrics.droppedRecordsSensorOrSkippedRecordsSensor; | ||
| import static org.apache.kafka.streams.processor.internals.metrics.ProcessorNodeMetrics.skippedIdempotentUpdatesSensor; | ||
|
|
||
| public class KTableSource<K, V> implements ProcessorSupplier<K, V> { | ||
| private static final Logger LOG = LoggerFactory.getLogger(KTableSource.class); | ||
|
|
@@ -74,10 +78,11 @@ public boolean materialized() { | |
|
|
||
| private class KTableSourceProcessor extends AbstractProcessor<K, V> { | ||
|
|
||
| private TimestampedKeyValueStore<K, V> store; | ||
| private MeteredTimestampedKeyValueStore<K, V> store; | ||
| private TimestampedTupleForwarder<K, V> tupleForwarder; | ||
| private StreamsMetricsImpl metrics; | ||
| private Sensor droppedRecordsSensor; | ||
| private Sensor skippedIdempotentUpdatesSensor = null; | ||
|
|
||
| @SuppressWarnings("unchecked") | ||
| @Override | ||
|
|
@@ -86,12 +91,21 @@ public void init(final ProcessorContext context) { | |
| metrics = (StreamsMetricsImpl) context.metrics(); | ||
| droppedRecordsSensor = droppedRecordsSensorOrSkippedRecordsSensor(Thread.currentThread().getName(), context.taskId().toString(), metrics); | ||
| if (queryableName != null) { | ||
| store = (TimestampedKeyValueStore<K, V>) context.getStateStore(queryableName); | ||
| final StateStore stateStore = context.getStateStore(queryableName); | ||
| try { | ||
| store = ((WrappedStateStore<MeteredTimestampedKeyValueStore<K, V>, K, V>) stateStore).wrapped(); | ||
| } catch (final ClassCastException e) { | ||
| throw new IllegalStateException("Unexpected store type: " + stateStore.getClass() + " for store: " + queryableName, e); | ||
| } | ||
| tupleForwarder = new TimestampedTupleForwarder<>( | ||
| store, | ||
| context, | ||
| new TimestampedCacheFlushListener<>(context), | ||
| sendOldValues); | ||
| skippedIdempotentUpdatesSensor = skippedIdempotentUpdatesSensor(Thread.currentThread().getName(), | ||
| context.taskId().toString(), | ||
| ((InternalProcessorContext) context).currentNode().name(), | ||
| metrics); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -108,7 +122,8 @@ public void process(final K key, final V value) { | |
| } | ||
|
|
||
| if (queryableName != null) { | ||
| final ValueAndTimestamp<V> oldValueAndTimestamp = store.get(key); | ||
| final MeteredTimestampedKeyValueStore<K, V>.RawAndDeserializedValue<V> tuple = store.getWithBinary(key); | ||
| final ValueAndTimestamp<V> oldValueAndTimestamp = tuple.value; | ||
| final V oldValue; | ||
| if (oldValueAndTimestamp != null) { | ||
| oldValue = oldValueAndTimestamp.value(); | ||
|
|
@@ -119,8 +134,13 @@ public void process(final K key, final V value) { | |
| } else { | ||
| oldValue = null; | ||
| } | ||
| store.put(key, ValueAndTimestamp.make(value, context().timestamp())); | ||
| tupleForwarder.maybeForward(key, value, oldValue); | ||
| final boolean isDifferentValue = | ||
| store.putIfDifferentValues(key, ValueAndTimestamp.make(value, context().timestamp()), tuple.serializedValue); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: If the record is out-of-order, shouldn't you put the value into the state store regardless of whether it is different from the old value or not? I think this would be more correct because the old value is actually the idempotent update to the new out-of-order value. This is one of the rare cases where we can correct the order. WDYT?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks @cadonna. I'd like to ask for clarification: Do you mean, given the following sequence of input records: We would take the following actions: This might be controversial. I believe that when we "complete" the implementation of timestamp semantics in Streams, the current proposal is to actually drop disordered updates. In that context, it might seem like a step in the wrong direction to specifically implement the code to preserve them when we would otherwise drop them. On the other hand, the current semantics do seem to be better maintained if we take your suggestion. Perhaps we should let the future take care of itself and implement correct semantics as we currently understand them. I.e., I'm +1 on your suggestion.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, this is what I mean and the current semantics were my motivation to ask the question.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These are good points! However, if this is the case, then we probably need to compare timestamps as well (i.e. to check if one is smaller than the other). We just need to modify the comparator in the serializer.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, added some changes to accomodate this in the test. |
||
| if (isDifferentValue) { | ||
| tupleForwarder.maybeForward(key, value, oldValue); | ||
| } else { | ||
| skippedIdempotentUpdatesSensor.record(); | ||
| } | ||
| } else { | ||
| context().forward(key, new Change<>(value, null)); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,6 +47,12 @@ private ProcessorNodeMetrics() {} | |
| private static final String SUPPRESSION_EMIT_RATE_DESCRIPTION = | ||
| RATE_DESCRIPTION_PREFIX + SUPPRESSION_EMIT_DESCRIPTION + RATE_DESCRIPTION_SUFFIX; | ||
|
|
||
| private static final String IDEMPOTENT_UPDATE_SKIP = "idempotent-update-skip"; | ||
| private static final String IDEMPOTENT_UPDATE_SKIP_DESCRIPTION = "skipped idempotent updates"; | ||
| private static final String IDEMPOTENT_UPDATE_SKIP_TOTAL_DESCRIPTION = TOTAL_DESCRIPTION + IDEMPOTENT_UPDATE_SKIP_DESCRIPTION; | ||
| private static final String IDEMPOTENT_UPDATE_SKIP_RATE_DESCRIPTION = | ||
| RATE_DESCRIPTION_PREFIX + IDEMPOTENT_UPDATE_SKIP + RATE_DESCRIPTION_SUFFIX; | ||
|
|
||
| private static final String PROCESS = "process"; | ||
| private static final String PROCESS_DESCRIPTION = "calls to process"; | ||
| private static final String PROCESS_TOTAL_DESCRIPTION = TOTAL_DESCRIPTION + PROCESS_DESCRIPTION; | ||
|
|
@@ -108,6 +114,22 @@ public static Sensor suppressionEmitSensor(final String threadId, | |
| ); | ||
| } | ||
|
|
||
| public static Sensor skippedIdempotentUpdatesSensor(final String threadId, | ||
| final String taskId, | ||
| final String processorNodeId, | ||
| final StreamsMetricsImpl streamsMetrics) { | ||
| return throughputSensor( | ||
| threadId, | ||
| taskId, | ||
| processorNodeId, | ||
| IDEMPOTENT_UPDATE_SKIP, | ||
| IDEMPOTENT_UPDATE_SKIP_RATE_DESCRIPTION, | ||
| IDEMPOTENT_UPDATE_SKIP_TOTAL_DESCRIPTION, | ||
| RecordingLevel.DEBUG, | ||
| streamsMetrics | ||
| ); | ||
| } | ||
|
|
||
|
Comment on lines
+117
to
+132
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. req: Please add a unit test in
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||
| public static Sensor processSensor(final String threadId, | ||
| final String taskId, | ||
| final String processorNodeId, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -16,9 +16,12 @@ | |||
| */ | ||||
| package org.apache.kafka.streams.state.internals; | ||||
|
|
||||
| import static org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl.maybeMeasureLatency; | ||||
|
|
||||
| import org.apache.kafka.common.serialization.Serde; | ||||
| import org.apache.kafka.common.utils.Bytes; | ||||
| import org.apache.kafka.common.utils.Time; | ||||
| import org.apache.kafka.streams.errors.ProcessorStateException; | ||||
| import org.apache.kafka.streams.processor.ProcessorContext; | ||||
| import org.apache.kafka.streams.processor.internals.ProcessorStateManager; | ||||
| import org.apache.kafka.streams.state.KeyValueStore; | ||||
|
|
@@ -35,7 +38,7 @@ | |||
| * @param <V> | ||||
| */ | ||||
| public class MeteredTimestampedKeyValueStore<K, V> | ||||
| extends MeteredKeyValueStore<K, ValueAndTimestamp<V>> | ||||
| extends MeteredKeyValueStore<K, ValueAndTimestamp<V>> | ||||
| implements TimestampedKeyValueStore<K, V> { | ||||
|
|
||||
| MeteredTimestampedKeyValueStore(final KeyValueStore<Bytes, byte[]> inner, | ||||
|
|
@@ -53,4 +56,47 @@ void initStoreSerde(final ProcessorContext context) { | |||
| keySerde == null ? (Serde<K>) context.keySerde() : keySerde, | ||||
| valueSerde == null ? new ValueAndTimestampSerde<>((Serde<V>) context.valueSerde()) : valueSerde); | ||||
| } | ||||
| } | ||||
|
|
||||
| public RawAndDeserializedValue<V> getWithBinary(final K key) { | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. req: Please add unit tests for this method.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||||
| try { | ||||
| final byte[] serializedValue = wrapped().get(keyBytes(key)); | ||||
| return new RawAndDeserializedValue<V>(serializedValue, | ||||
| maybeMeasureLatency(() -> outerValue(serializedValue), time, getSensor)); | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, missed this the last time though. We should also perform the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, I will make the change. |
||||
| } catch (final ProcessorStateException e) { | ||||
| final String message = String.format(e.getMessage(), key); | ||||
| throw new ProcessorStateException(message, e); | ||||
| } | ||||
| } | ||||
|
|
||||
| public boolean putIfDifferentValues(final K key, | ||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. req: Please add unit tests for this method.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. |
||||
| final ValueAndTimestamp<V> newValue, | ||||
| final byte[] oldSerializedValue) { | ||||
| try { | ||||
| return maybeMeasureLatency( | ||||
| () -> { | ||||
| final byte[] newSerializedValue = serdes.rawValue(newValue); | ||||
| if (ValueAndTimestampSerializer.maskTimestampAndCompareValues(oldSerializedValue, newSerializedValue)) { | ||||
| return false; | ||||
| } else { | ||||
| wrapped().put(keyBytes(key), newSerializedValue); | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @ConcurrencyPractitioner @vvcephei I'm trying to understand this to debug some broken tests in ksql. Couple questions: When the timestamp of the newer value is lower (ignoring the value), why do we want to put the new value into the store? Surely the store should have the value with the newer timestamp? Otherwise we could wind up with a corrupt store. Don't we still want to put the value in the store (even if we don't forward it on to the next context) if the values are the same but the timestamp is newer? Otherwise if we get an out-of-order update with a different value, but a timestamp in between the rows with the same value, we'd incorrectly put that value into the store, e.g. the following updates: TS: 1, K: X, V: A would result in the table containing
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This behavior was there also before this PR. If a out-of-order record is encountered, a log message was written, but the record was nevertheless put into the state store (cf. kafka/streams/src/main/java/org/apache/kafka/streams/kstream/internals/KTableSource.java Line 122 in 7624e62
Could you elaborate on why a store should get corrupted because of this?
As said above, this behavior should not have been changed.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If we just put the value in the store but did not forward it, then the store would actually be corrupted, because the local state would not be consistent with downstream anymore. Not putting a record with the same value but a newer timestamp in the store and not forwarding it was the main point of this KIP. |
||||
| return true; | ||||
| } | ||||
| }, | ||||
| time, | ||||
| putSensor | ||||
| ); | ||||
| } catch (final ProcessorStateException e) { | ||||
| final String message = String.format(e.getMessage(), key, newValue); | ||||
| throw new ProcessorStateException(message, e); | ||||
| } | ||||
| } | ||||
|
|
||||
| public class RawAndDeserializedValue<ValueType> { | ||||
| public final byte[] serializedValue; | ||||
| public final ValueAndTimestamp<ValueType> value; | ||||
| public RawAndDeserializedValue(final byte[] serializedValue, final ValueAndTimestamp<ValueType> value) { | ||||
| this.serializedValue = serializedValue; | ||||
| this.value = value; | ||||
| } | ||||
| } | ||||
| } | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,25 @@ public class ValueAndTimestampSerializer<V> implements Serializer<ValueAndTimest | |
| timestampSerializer = new LongSerializer(); | ||
| } | ||
|
|
||
| public static boolean maskTimestampAndCompareValues(final byte[] left, final byte[] right) { | ||
|
vvcephei marked this conversation as resolved.
Outdated
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, upon reading that last test, I realized that I previously overlooked when you added the timestamp comparison to this method. We should change the method name for maintainability. It no longer just "masks the timestamp and compares the values". Can we instead call it "compareValuesAndCheckForIncreasingTimestamp" or something? I can almost guarantee that one or more people will be badly misled by the current method name. |
||
| // adapted from Arrays.equals | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. prop: Delete this comment since it is not very useful. |
||
| if (left == right) | ||
| return true; | ||
| if (left == null || right == null) | ||
| return false; | ||
|
|
||
| final int length = left.length; | ||
| if (right.length != length) | ||
| return false; | ||
|
|
||
| // skip the timestamp when comparing just the values | ||
| for (int i = Long.BYTES; i < length; i++) | ||
| if (left[i] != right[i]) | ||
| return false; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. prop: Please use braces to delimit the scopes of the loop and the |
||
|
|
||
| return true; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. prop: Instead of adding the comment, factor out this code to a method named |
||
| } | ||
|
|
||
| @Override | ||
| public void configure(final Map<String, ?> configs, | ||
| final boolean isKey) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,8 +16,10 @@ | |
| */ | ||
| package org.apache.kafka.streams.kstream.internals; | ||
|
|
||
| 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.KeyValueTimestamp; | ||
| import org.apache.kafka.streams.StreamsBuilder; | ||
|
|
@@ -32,7 +34,9 @@ | |
| import org.apache.kafka.streams.processor.internals.InternalTopologyBuilder; | ||
| import org.apache.kafka.streams.processor.internals.testutil.LogCaptureAppender; | ||
| import org.apache.kafka.streams.state.ValueAndTimestamp; | ||
| import org.apache.kafka.streams.test.TestRecord; | ||
| import org.apache.kafka.streams.TestInputTopic; | ||
| import org.apache.kafka.streams.TestOutputTopic; | ||
| import org.apache.kafka.test.MockProcessor; | ||
| import org.apache.kafka.test.MockProcessorSupplier; | ||
| import org.apache.kafka.test.StreamsTestUtils; | ||
|
|
@@ -85,6 +89,40 @@ public void testKTable() { | |
| supplier.theCapturedProcessor().processed); | ||
| } | ||
|
|
||
| @Test | ||
| public void testKTableSourceEmitOnChange() { | ||
|
Comment on lines
+95
to
+96
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q: Why do you need the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that we can just follow the previous test format, so I think there's not too much need to change it at the moment. |
||
| final StreamsBuilder builder = new StreamsBuilder(); | ||
| final String topic1 = "topic1"; | ||
|
|
||
| builder.table(topic1, Consumed.with(Serdes.String(), Serdes.Integer()), Materialized.as("store")) | ||
| .toStream() | ||
| .to("output"); | ||
|
|
||
| try (final TopologyTestDriver driver = new TopologyTestDriver(builder.build(), props)) { | ||
| final TestInputTopic<String, Integer> inputTopic = | ||
| driver.createInputTopic(topic1, new StringSerializer(), new IntegerSerializer()); | ||
| final TestOutputTopic<String, Integer> outputTopic = | ||
| driver.createOutputTopic("output", new StringDeserializer(), new IntegerDeserializer()); | ||
|
|
||
| inputTopic.pipeInput("A", 1, 10L); | ||
| inputTopic.pipeInput("B", 2, 11L); | ||
| inputTopic.pipeInput("A", 1, 12L); | ||
| inputTopic.pipeInput("B", 3, 13L); | ||
|
|
||
| assertEquals( | ||
| 1.0, | ||
| getMetricByName(driver.metrics(), "idempotent-update-skip-total", "stream-processor-node-metrics").metricValue() | ||
| ); | ||
|
|
||
| assertEquals( | ||
| asList(new TestRecord<>("A", 1, Instant.ofEpochMilli(10L)), | ||
| new TestRecord<>("B", 2, Instant.ofEpochMilli(11L)), | ||
| new TestRecord<>("B", 3, Instant.ofEpochMilli(13L))), | ||
| outputTopic.readRecordsToList() | ||
| ); | ||
| } | ||
| } | ||
|
ConcurrencyPractitioner marked this conversation as resolved.
|
||
|
|
||
| @Test | ||
| public void kTableShouldLogAndMeterOnSkippedRecordsWithBuiltInMetrics0100To24() { | ||
| kTableShouldLogAndMeterOnSkippedRecords(StreamsConfig.METRICS_0100_TO_24); | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.