mjsax commented on code in PR #22165:
URL: https://github.com/apache/kafka/pull/22165#discussion_r3661685733
##########
streams/src/test/java/org/apache/kafka/streams/state/internals/TimeOrderedKeyValueBufferTest.java:
##########
@@ -306,10 +306,74 @@ public void shouldReturnPriorValueForBufferedKey(final
String testName, final Fu
context.setRecordContext(recordContext);
buffer.put(1L, new Record<>("A", new Change<>("new-value",
"old-value"), 0L), recordContext);
buffer.put(1L, new Record<>("B", new Change<>("new-value", null), 0L),
recordContext);
- assertThat(buffer.priorValueForBuffered("A"),
is(Maybe.defined(ValueTimestampHeaders.make("old-value", -1, new
RecordHeaders()))));
+ assertThat(buffer.priorValueForBuffered("A"),
is(Maybe.defined(ValueTimestampHeaders.make("old-value", 0L, new
RecordHeaders()))));
assertThat(buffer.priorValueForBuffered("B"), is(Maybe.defined(null)));
}
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void shouldPropagateHeadersThroughEviction(final String testName,
final Function<String, B> bufferSupplier) {
+ setup(testName, bufferSupplier);
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>> buffer
= bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> context = makeContext();
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h1", "v1".getBytes(UTF_8))});
Review Comment:
It's always better to specify it, to stay platform independent.
##########
streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java:
##########
@@ -0,0 +1,616 @@
+/*
+ * 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.kstream.internals;
+
+import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.serialization.Deserializer;
+import org.apache.kafka.common.serialization.Serde;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.serialization.Serializer;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.TestInputTopic;
+import org.apache.kafka.streams.Topology;
+import org.apache.kafka.streams.TopologyTestDriver;
+import org.apache.kafka.streams.TopologyTestDriverBuilder;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.streams.kstream.Grouped;
+import org.apache.kafka.streams.kstream.KTable;
+import org.apache.kafka.streams.kstream.Materialized;
+import org.apache.kafka.streams.kstream.Produced;
+import org.apache.kafka.streams.kstream.TimeWindows;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.WindowStore;
+import org.apache.kafka.streams.test.TestRecord;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Properties;
+
+import static java.time.Duration.ofMillis;
+import static
org.apache.kafka.streams.kstream.Suppressed.BufferConfig.unbounded;
+import static org.apache.kafka.streams.kstream.Suppressed.untilTimeLimit;
+import static org.apache.kafka.streams.kstream.Suppressed.untilWindowCloses;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Scenario tests for record-header propagation through {@code suppress()}.
+ *
+ * <p>The suppress buffer holds a row per key containing an {@code old} and a
{@code new} value,
+ * plus a single record context. Each of those parts originates from a
+ * <em>different</em> input record, so each must be serialized and
deserialized with the headers of
+ * the record it came from. Getting this wrong is invisible with
header-agnostic serdes such as
+ * {@link Serdes#String()}, because they discard the headers they are handed.
These tests therefore
+ * use serdes that observe every invocation, and assert on what the serdes
actually saw rather than
+ * only on what reached the output topic.
+ *
+ * <p>Each input record carries the headers it expects to see preserved, as
{@code origin=<value>},
+ * so every value should always meet its own marker; see {@link
#observingSerde}.
+ *
+ * <h3>Invariants asserted</h3>
+ * <ul>
+ * <li><b>INV-1</b> — every value (de)serialized by the suppress buffer must
be handed the headers
+ * belonging to that value, i.e. {@code origin=<value>}.</li>
+ * <li><b>INV-2</b> — the record emitted on eviction must carry the headers
belonging to the value
+ * being emitted.</li>
+ * <li><b>INV-3</b> — reported, not enforced: which headers the suppress
buffer hands to the
+ * <em>key</em> serde. The key is shared by all parts of a row, so there
is no single "correct"
+ * answer to pin down yet; the observations are printed so the behaviour
is visible.</li>
+ * </ul>
+ *
+ * <p>All checks are collected and reported together: a scenario runs to
completion, prints a full
+ * trace plus a check summary to stdout, and only then makes a single
assertion. That keeps the whole
+ * picture visible instead of stopping at the first deviation.
+ *
+ * <h3>Scenario coverage</h3>
+ *
+ * The buffer never sees {@code Windowed}: it stores serialized key bytes, and
windowing reaches
+ * {@code suppress()} only through the time definition and through {@code
safeToDropTombstones},
+ * neither of which affects header handling. Windowed and non-windowed
scenarios therefore drive the
+ * same code, and the matrix below is deliberately not filled in exhaustively.
+ *
+ * <p>In particular there is <b>no</b> windowed /
eviction-triggered-by-a-different-key scenario. That
+ * is omitted on purpose rather than missing: it would drive the same buffer
path as
+ * {@link #nonWindowedEvictionTriggeredByDifferentKey()} with nothing added.
The windowed scenarios
+ * that are here earn their place either by recording row-identity semantics
that are easy to get
+ * wrong, or by being reachable only when windowed.
+ *
+ * <h3>Why the same-row scenarios require the HEADERS store format</h3>
+ *
+ * When the evicting record updates a row that is already buffered, that row
ends up holding an
+ * {@code old} and a {@code new} value originating from two different records,
so INV-1 requires two
+ * different sets of headers to be recoverable from one row. The plain format
has nowhere to put
+ * them: it stores bare values and a single record context, so at most one of
the two can be right.
+ * INV-1 is therefore unsatisfiable in plain format for these scenarios, no
matter how
+ * {@code suppress()} is implemented.
+ *
+ * <p>{@link #nonWindowedEvictionTriggeredBySameKey()},
+ * {@link #windowedEvictionTriggeredBySameKeySameWindow()},
+ * {@link #nonWindowedTombstoneMustBeForwarded()} and
+ * {@link #windowedFinalResultsTombstoneIsDropped()} consequently set
+ * {@code dsl.store.format=headers}, where each value part carries its own
headers. Running them in
+ * plain format would assert something impossible and read as a permanent bug.
The two tombstone
+ * scenarios belong in that group as well: a delete overwrites the row it
lands on, so the row still
+ * holds an {@code old} value from the earlier record alongside the {@code
new} null.
+ *
+ * <p>Scenarios where the evicted row is <em>not</em> the one being updated
have only one origin per
+ * row, so they are satisfiable in plain format and deliberately stay there.
+ */
+public class SuppressHeadersScenarioTest {
Review Comment:
The other PR was merged, so this class should drop after a rebase -- we
still need to enable the tests of course.
##########
streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java:
##########
@@ -203,6 +222,10 @@ public void init(final StateStoreContext
stateStoreContext, final StateStore roo
taskId = context.taskId().toString();
streamsMetrics = context.metrics();
+ final Object dslStoreFormat =
stateStoreContext.appConfigs().get(StreamsConfig.DSL_STORE_FORMAT_CONFIG);
Review Comment:
Is there a good place in the docs, to call this out? -- Should we file a
Jira ticket about "allow enabling header-support on suppress() similar to
`Materialized` for other operators"?
##########
streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java:
##########
@@ -73,6 +75,12 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
private static final byte[] V_3_CHANGELOG_HEADER_VALUE = {(byte) 3};
static final RecordHeaders CHANGELOG_HEADERS =
new RecordHeaders(new Header[] {new RecordHeader("v",
V_3_CHANGELOG_HEADER_VALUE)});
+ // Each buffered value part's headers/timestamp travel in the changelog
record's own Kafka headers -- one per part,
+ // holding that part's [headersSize][headers][timestamp] prefix -- so the
value bytes stay V3 and remain restorable
+ // by older versions, which simply ignore these headers.
+ static final String PRIOR_VALUE_HEADERS_KEY = "vh.prior";
+ static final String OLD_VALUE_HEADERS_KEY = "vh.old";
+ static final String NEW_VALUE_HEADERS_KEY = "vh.new";
Review Comment:
We might not need this last one after all? The `context` that we already
encode should contain the `newValue` header and timestamp already, so no need
to put into the headers a second time?
##########
streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java:
##########
@@ -465,28 +512,136 @@ private byte[] internalPriorValueForBuffered(final Bytes
key) {
}
}
+ // The legacy V0/V1 restore paths feed the currently-buffered prior value
back in as plain value
+ // bytes, so unwrap it when the in-memory encoding is the headers-aware
format.
+ private byte[] plainPriorValueForBuffered(final Bytes key) {
+ final byte[] priorValue = internalPriorValueForBuffered(key);
+ return storeHeaders ? Utils.rawPlainValue(priorValue) : priorValue;
+ }
+
+ // Normalizes a restored buffer value so its value parts match the
encoding currently used
+ // in-memory (ValueTimestampHeaders blobs when header stores are enabled,
plain values otherwise),
+ // regardless of the changelog version it was read from.
+ private BufferValue normalizeEncoding(final BufferValue value, final
boolean restoredWithHeaders) {
+ if (storeHeaders == restoredWithHeaders) {
+ return value;
+ }
+ if (storeHeaders) {
+ // Plain -> ValueTimestampHeaders. The original headers/timestamp
of legacy records are
+ // unknown, so we use empty headers and the record-context
timestamp.
+ final long timestamp = value.context().timestamp();
+ return new BufferValue(
+ Utils.rawValueTimestampHeaders(value.priorValue(), timestamp),
+ Utils.rawValueTimestampHeaders(value.oldValue(), timestamp),
+ Utils.rawValueTimestampHeaders(value.newValue(), timestamp),
+ value.context()
+ );
+ }
+ // ValueTimestampHeaders -> plain would drop the per-value
headers/timestamps carried by a V4
+ // changelog. This only arises when a store that previously used a
headers store format is
+ // restored without one, i.e. a store-format downgrade. Downgrades are
not supported, so we
+ // fail fast here rather than silently discarding the stored header
data.
+ throw new StreamsException(
+ "Cannot restore suppress buffer '" + storeName + "': its changelog
contains header-aware "
+ + "(V4) records, but the store is configured without a headers
store format. "
+ + "Downgrading the store format is not supported."
+ );
+ }
+
+ private ValueTimestampHeadersSerializer<V>
valueTimestampHeadersSerializer() {
+ if (valueTimestampHeadersSerializer == null) {
+ valueTimestampHeadersSerializer = new
ValueTimestampHeadersSerializer<>(valueSerde.innerSerde().serializer());
+ }
+ return valueTimestampHeadersSerializer;
+ }
+
+ private ValueTimestampHeadersDeserializer<V>
valueTimestampHeadersDeserializer() {
+ if (valueTimestampHeadersDeserializer == null) {
+ valueTimestampHeadersDeserializer = new
ValueTimestampHeadersDeserializer<>(valueSerde.innerSerde().deserializer());
+ }
+ return valueTimestampHeadersDeserializer;
+ }
+
+ // Serialize a single value part. When storeHeaders is set, the value is
wrapped as a
+ // ValueTimestampHeaders blob carrying the given timestamp and headers;
otherwise the plain
+ // value bytes are stored (the pre-existing V3 behavior). Returns null for
a null value.
+ private byte[] serializeValuePart(final V value, final long timestamp,
final Headers headers) {
+ if (value == null) {
+ return null;
+ }
+ if (storeHeaders) {
+ return valueTimestampHeadersSerializer().serialize(changelogTopic,
ValueTimestampHeaders.make(value, timestamp, headers));
+ }
+ return valueSerde.innerSerde().serializer().serialize(changelogTopic,
headers, value);
+ }
+
+ // Deserialize a single stored value part into a ValueTimestampHeaders.
Only valid when
+ // storeHeaders is set (i.e. the part bytes are a ValueTimestampHeaders
blob).
+ private ValueTimestampHeaders<V> deserializeValuePart(final byte[] bytes) {
+ return bytes == null ? null :
valueTimestampHeadersDeserializer().deserialize(changelogTopic, bytes);
+ }
+
+ // Deserialize a single stored value part into the plain value, handling
both the headers-aware
+ // (ValueTimestampHeaders) and plain encodings.
+ private V deserializeValue(final byte[] bytes, final Headers
fallbackHeaders) {
+ if (bytes == null) {
+ return null;
+ }
+ if (storeHeaders) {
+ return
ValueTimestampHeaders.getValueOrNull(deserializeValuePart(bytes));
+ }
+ return
valueSerde.innerSerde().deserializer().deserialize(changelogTopic,
fallbackHeaders, bytes);
+ }
+
@Override
public boolean put(final long time,
final Record<K, Change<V>> record,
final ProcessorRecordContext recordContext) {
requireNonNull(record.value(), "value cannot be null");
requireNonNull(recordContext, "recordContext cannot be null");
- final Bytes serializedKey =
Bytes.wrap(keySerde.serializer().serialize(changelogTopic,
recordContext.headers(), record.key()));
- final Change<byte[]> serialChange =
valueSerde.serializeParts(changelogTopic, recordContext.headers(),
record.value());
-
+ // The record's own headers (not the processing context's) describe
the new value and must be
+ // the ones forwarded for this key on eviction.
+ final RecordHeaders headers = new RecordHeaders(record.headers());
+ final long timestamp = record.timestamp();
+ final Bytes serializedKey =
Bytes.wrap(keySerde.serializer().serialize(changelogTopic, headers,
record.key()));
final BufferValue buffered = getBuffered(serializedKey);
- final byte[] serializedPriorValue;
- if (buffered == null) {
- serializedPriorValue = serialChange.oldValue;
- } else {
- serializedPriorValue = buffered.priorValue();
+
+ // The context stored with the entry carries the currently-processed
record's headers; these
+ // are what get forwarded downstream when the (new) value is emitted.
Everything else is taken
+ // from the processing context, unchanged from before.
+ final ProcessorRecordContext effectiveContext = new
ProcessorRecordContext(
+ recordContext.timestamp(),
+ recordContext.offset(),
+ recordContext.partition(),
+ recordContext.topic(),
+ headers
+ );
+
+ // The old value's original headers/timestamp are not carried by the
incoming record. On an
+ // in-place update we recover them from the entry's previous new value
(whose value is exactly
+ // this update's old value); on the first insert for a key they are
genuinely unknown.
+ Headers oldHeaders = new RecordHeaders();
+ long oldTimestamp = RecordQueue.UNKNOWN;
+ if (storeHeaders && buffered != null) {
+ final ValueTimestampHeaders<V> previousNewValue =
deserializeValuePart(buffered.newValue());
+ if (previousNewValue != null) {
+ oldHeaders = previousNewValue.headers();
+ oldTimestamp = previousNewValue.timestamp();
+ }
}
+ final Change<V> change = record.value();
+ final byte[] newValue = serializeValuePart(change.newValue, timestamp,
headers);
+ // In plain mode the old value is still serialized with the current
record's headers, exactly
+ // as before, so the stored bytes are unchanged for non-header stores.
+ final byte[] oldValue = serializeValuePart(change.oldValue,
oldTimestamp, storeHeaders ? oldHeaders : headers);
Review Comment:
I think the PR is correct. We should not "pollute" the newValue headers with
stuff from oldValue, to having oldValue isolated in headers mode, is actually a
fix IMHO.
##########
streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java:
##########
@@ -399,14 +448,43 @@ public void evictWhile(final Supplier<Boolean> predicate,
next.getKey().time() + "]"
);
}
- final K key =
keySerde.deserializer().deserialize(changelogTopic, context.headers(),
next.getKey().key().get());
final BufferValue bufferValue = next.getValue();
- final Change<V> value = valueSerde.deserializeParts(
- changelogTopic,
- context.headers(),
- new Change<>(bufferValue.newValue(),
bufferValue.oldValue())
- );
- callback.accept(new Eviction<K, Change<V>>(key, value,
bufferValue.context()));
+ final ProcessorRecordContext bufferedContext =
bufferValue.context();
+
+ final K key;
+ final Change<V> value;
+ final ProcessorRecordContext evictionContext;
+ if (storeHeaders) {
+ // Each value part was stored as a ValueTimestampHeaders
blob carrying the headers of
+ // the record it originated from, so take them from the
value rather than from the
+ // buffered context: the old value came from an earlier
record than the new one, and
+ // deserializing it with the new record's headers would
hand the serde the wrong ones.
+ // (ValueTimestampHeadersDeserializer feeds each part's
own headers to the inner
+ // value deserializer.)
+ final ValueTimestampHeaders<V> newPart =
deserializeValuePart(bufferValue.newValue());
Review Comment:
I find the name `deserializeValuePart` confusing. We are getting back a vth
object, and we only pass in the corresponding rawVTH byte[] array. Not sure
what "part" refers to?
##########
streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java:
##########
@@ -465,28 +569,135 @@ private byte[] internalPriorValueForBuffered(final Bytes
key) {
}
}
+ // The legacy V0/V1 restore paths feed the currently-buffered prior value
back in as plain value
+ // bytes, so unwrap it when the in-memory encoding is the headers-aware
format.
+ private byte[] plainPriorValueForBuffered(final Bytes key) {
+ final byte[] priorValue = internalPriorValueForBuffered(key);
+ return storeHeaders ? Utils.rawPlainValue(priorValue) : priorValue;
+ }
+
+ // The changelog value format is plain for every version, so a restored
buffer value always has
+ // plain value parts. When header stores are enabled the in-memory
encoding is
+ // ValueTimestampHeaders, so re-attach each part's headers and timestamp
from the changelog
+ // record's own Kafka headers. Records written before this feature -- or
by a run configured
+ // without header stores -- carry no such headers, in which case the
originals are genuinely
+ // unknown and we fall back to empty headers with the record-context
timestamp.
+ private BufferValue toInMemoryEncoding(final BufferValue value, final
Headers recordHeaders) {
+ if (!storeHeaders) {
+ return value;
+ }
+ final long fallbackTimestamp = value.context().timestamp();
+ return new BufferValue(
+ valuePartWithHeaders(value.priorValue(), recordHeaders,
PRIOR_VALUE_HEADERS_KEY, fallbackTimestamp),
+ valuePartWithHeaders(value.oldValue(), recordHeaders,
OLD_VALUE_HEADERS_KEY, fallbackTimestamp),
+ valuePartWithHeaders(value.newValue(), recordHeaders,
NEW_VALUE_HEADERS_KEY, fallbackTimestamp),
+ value.context()
+ );
+ }
+
+ private static byte[] valuePartWithHeaders(final byte[] rawPlainValue,
+ final Headers recordHeaders,
+ final String headerKey,
+ final long fallbackTimestamp) {
+ if (rawPlainValue == null) {
+ return null;
+ }
+ final Header prefix = recordHeaders.lastHeader(headerKey);
Review Comment:
Why `prefix`? The name is confusing. We store ts+headers in the `Header` to
should this not be `tsAndHeader` as variable name?
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]