This is an automated email from the ASF dual-hosted git repository.
mjsax pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new b706c58dd91 KAFKA-20413: Make Streams suppress headers-aware (#22165)
b706c58dd91 is described below
commit b706c58dd91f2f331a91d8b94b15b0912d1a8242
Author: Alieh Saeedi <[email protected]>
AuthorDate: Wed Jul 29 08:17:54 2026 +0200
KAFKA-20413: Make Streams suppress headers-aware (#22165)
This PR fixes the suppress-buffer part of KAFKA-20413 / KIP-1285.
`InMemoryTimeOrderedKeyValueChangeBuffer` now uses the buffered record's
own headers in serde calls and on eviction.
Testing: Added `shouldPropagateHeadersThroughEviction`, and
`shouldUseRecordHeadersNotProcessorContextHeadersOnPut`.
Reviewers: Matthias J. Sax <[email protected]>, Uladzislau Blok
<[email protected]>
---
.../InMemoryTimeOrderedKeyValueChangeBuffer.java | 301 +++++++++--
.../kafka/streams/state/internals/Utils.java | 83 +++
.../internals/SuppressHeadersScenarioTest.java | 28 +-
.../internals/TimeOrderedKeyValueBufferTest.java | 597 +++++++++++++++++++++
.../kafka/streams/state/internals/UtilsTest.java | 74 +++
5 files changed, 1039 insertions(+), 44 deletions(-)
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java
index 04e6911d1f7..449f0240faa 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTimeOrderedKeyValueChangeBuffer.java
@@ -19,6 +19,7 @@ package org.apache.kafka.streams.state.internals;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.apache.kafka.common.metrics.Sensor;
@@ -26,6 +27,7 @@ import
org.apache.kafka.common.serialization.ByteArraySerializer;
import org.apache.kafka.common.serialization.BytesSerializer;
import org.apache.kafka.common.serialization.Serde;
import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.kstream.internals.Change;
import org.apache.kafka.streams.kstream.internals.FullChangeSerde;
import org.apache.kafka.streams.processor.StateStore;
@@ -73,6 +75,13 @@ 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)});
+ // The prior and old value parts' 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. The
new value part needs no such header: the
+ // record context encoded in the V3 value already describes it, because
put() captures the context of the very
+ // record the new value came from.
+ static final String PRIOR_VALUE_HEADERS_KEY = "vh.prior";
+ static final String OLD_VALUE_HEADERS_KEY = "vh.old";
private static final String METRIC_SCOPE = "in-memory-suppression";
private final Map<Bytes, BufferKey> index = new HashMap<>();
@@ -85,6 +94,14 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
private Serde<K> keySerde;
private FullChangeSerde<V> valueSerde;
+ // When headers-aware stores are enabled (dsl.store.format=HEADERS) each
buffered value part is
+ // stored as a ValueTimestampHeaders blob so that the old and new value
can carry their own
+ // headers and timestamp independently. Otherwise plain values are stored
(the pre-existing V3
+ // behavior) to avoid inflating memory/changelog size for users who do not
use header stores.
+ private boolean storeHeaders;
+ private ValueTimestampHeadersSerializer<V> valueTimestampHeadersSerializer;
+ private ValueTimestampHeadersDeserializer<V>
valueTimestampHeadersDeserializer;
+
private long memBufferSize = 0L;
private long minTimestamp = Long.MAX_VALUE;
private InternalProcessorContext<?, ?> context;
@@ -203,6 +220,10 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
taskId = context.taskId().toString();
streamsMetrics = context.metrics();
+ final Object dslStoreFormat =
stateStoreContext.appConfigs().get(StreamsConfig.DSL_STORE_FORMAT_CONFIG);
+ storeHeaders = dslStoreFormat != null
+ &&
StreamsConfig.DSL_STORE_FORMAT_HEADERS.equalsIgnoreCase(dslStoreFormat.toString());
+
bufferSizeSensor = StateStoreMetrics.suppressionBufferSizeSensor(
taskId,
METRIC_SCOPE,
@@ -268,14 +289,17 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
private void logValue(final Bytes key, final BufferKey bufferKey, final
BufferValue value) {
final int sizeOfBufferTime = Long.BYTES;
- final ByteBuffer buffer = value.serialize(sizeOfBufferTime);
+ // The logged value is always in the V3 format: in headers mode the
in-memory value parts are
+ // ValueTimestampHeaders blobs, so split each one and log only its
plain value bytes, sending
+ // the headers/timestamp prefixes alongside in the record's Kafka
headers.
+ final ByteBuffer buffer = (storeHeaders ? plainEncoded(value) :
value).serialize(sizeOfBufferTime);
buffer.putLong(bufferKey.time());
final byte[] array = buffer.array();
((RecordCollector.Supplier) context).recordCollector().send(
changelogTopic,
key,
array,
- CHANGELOG_HEADERS,
+ storeHeaders ? changelogHeadersWithValuePartHeaders(value) :
CHANGELOG_HEADERS,
partition,
null,
KEY_SERIALIZER,
@@ -284,6 +308,36 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
null);
}
+ private static BufferValue plainEncoded(final BufferValue value) {
+ return new BufferValue(
+ Utils.rawPlainValue(value.priorValue()),
+ Utils.rawPlainValue(value.oldValue()),
+ Utils.rawPlainValue(value.newValue()),
+ value.context()
+ );
+ }
+
+ private static RecordHeaders changelogHeadersWithValuePartHeaders(final
BufferValue value) {
+ final RecordHeaders headers = new RecordHeaders(new Header[] {new
RecordHeader("v", V_3_CHANGELOG_HEADER_VALUE)});
+ // BufferValue collapses the prior and old values onto one array
whenever they are equal -- the
+ // common case, since on the first buffering of a key the prior value
IS the old value -- and
+ // serialize() then writes those bytes only once. Skip the duplicate
prefix as well; restoring
+ // recovers the prior part from vh.old whenever vh.prior is absent.
+ if (value.priorValue() != value.oldValue()) {
+ addValuePartHeader(headers, PRIOR_VALUE_HEADERS_KEY,
value.priorValue());
+ }
+ addValuePartHeader(headers, OLD_VALUE_HEADERS_KEY, value.oldValue());
+ return headers;
+ }
+
+ private static void addValuePartHeader(final RecordHeaders headers,
+ final String headerKey,
+ final byte[]
rawValueTimestampHeaders) {
+ if (rawValueTimestampHeaders != null) {
+ headers.add(new RecordHeader(headerKey,
Utils.rawHeadersTimestampPrefix(rawValueTimestampHeaders)));
+ }
+ }
+
private void logTombstone(final Bytes key) {
((RecordCollector.Supplier) context).recordCollector().send(
changelogTopic,
@@ -324,6 +378,7 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
}
} else {
final Header versionHeader = record.headers().lastHeader("v");
+ final DeserializationResult deserializationResult;
if (versionHeader == null) {
// Version 0:
// value:
@@ -331,10 +386,9 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
// - old value
// - new value
final byte[] previousBufferedValue = index.containsKey(key)
- ? internalPriorValueForBuffered(key)
+ ? plainPriorValueForBuffered(key)
: null;
- final DeserializationResult deserializationResult =
deserializeV0(record, key, previousBufferedValue);
- cleanPut(deserializationResult.time(),
deserializationResult.key(), deserializationResult.bufferValue());
+ deserializationResult = deserializeV0(record, key,
previousBufferedValue);
} else if (Arrays.equals(versionHeader.value(),
V_3_CHANGELOG_HEADER_VALUE)) {
// Version 3:
// value:
@@ -343,9 +397,7 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
// - old value
// - new value
// - buffer time
- final DeserializationResult deserializationResult =
deserializeV3(record, key);
- cleanPut(deserializationResult.time(),
deserializationResult.key(), deserializationResult.bufferValue());
-
+ deserializationResult = deserializeV3(record, key);
} else if (Arrays.equals(versionHeader.value(),
V_2_CHANGELOG_HEADER_VALUE)) {
// Version 2:
// value:
@@ -356,8 +408,7 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
// - buffer time
// NOTE: 2.4.0, 2.4.1, and 2.5.0 actually encode Version 3
formatted data,
// but still set the Version 2 flag, so to deserialize, we
have to duck type.
- final DeserializationResult deserializationResult =
duckTypeV2(record, key);
- cleanPut(deserializationResult.time(),
deserializationResult.key(), deserializationResult.bufferValue());
+ deserializationResult = duckTypeV2(record, key);
} else if (Arrays.equals(versionHeader.value(),
V_1_CHANGELOG_HEADER_VALUE)) {
// Version 1:
// value:
@@ -366,13 +417,17 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
// - old value
// - new value
final byte[] previousBufferedValue = index.containsKey(key)
- ? internalPriorValueForBuffered(key)
+ ? plainPriorValueForBuffered(key)
: null;
- final DeserializationResult deserializationResult =
deserializeV1(record, key, previousBufferedValue);
- cleanPut(deserializationResult.time(),
deserializationResult.key(), deserializationResult.bufferValue());
+ deserializationResult = deserializeV1(record, key,
previousBufferedValue);
} else {
throw new IllegalArgumentException("Restoring apparently
invalid changelog record: " + record);
}
+ cleanPut(
+ deserializationResult.time(),
+ deserializationResult.key(),
+ toInMemoryEncoding(deserializationResult.bufferValue(),
record.headers())
+ );
}
}
updateBufferMetrics();
@@ -399,14 +454,51 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
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 =
deserializeValueTimestampHeaders(bufferValue.newValue());
+ final ValueTimestampHeaders<V> oldPart =
deserializeValueTimestampHeaders(bufferValue.oldValue());
+ // A tombstone has no new value to take headers from; fall
back to the buffered context.
+ // That fallback is not a second-best guess: the buffered
context and the new value's
+ // embedded headers always agree. put() hands the very
same record.headers() instance to
+ // both, and ValueTimestampHeadersSerializer snapshots the
headers only AFTER running the
+ // inner value serializer, so even a serde that writes
into the headers it is given (a
+ // Schema Registry serde recording the schema id, say) is
reflected in both. Restoring
+ // preserves the equality, because toInMemoryEncoding
rebuilds the new part from that
+ // same context. Only the old and prior parts can differ
from the context, which is why
+ // they are the ones read from the value here.
+ final Headers newHeaders = newPart == null ?
bufferedContext.headers() : newPart.headers();
+ key = keySerde.deserializer().deserialize(changelogTopic,
newHeaders, next.getKey().key().get());
+ value = new Change<>(
+ ValueTimestampHeaders.getValueOrNull(newPart),
+ ValueTimestampHeaders.getValueOrNull(oldPart)
+ );
+ // The emitted record carries the new value's headers, so
hand them to the callback
+ // via the eviction's context (which is what the suppress
processor forwards).
+ evictionContext = withHeaders(bufferedContext, newHeaders);
+ } else {
+ // Plain values store no headers of their own; the
buffered context is the only
+ // carrier, exactly as before.
+ final Headers headers = bufferedContext.headers();
+ key = keySerde.deserializer().deserialize(changelogTopic,
headers, next.getKey().key().get());
+ value = new Change<>(
+ deserializeValue(bufferValue.newValue(), headers),
+ deserializeValue(bufferValue.oldValue(), headers)
+ );
+ evictionContext = bufferedContext;
+ }
+ callback.accept(new Eviction<K, Change<V>>(key, value,
evictionContext));
delegate.remove();
index.remove(next.getKey().key());
@@ -434,15 +526,41 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
}
}
+ // Copy of the given context with different headers, preserving everything
else -- including
+ // sourceRawKey/sourceRawValue, which the shorter ProcessorRecordContext
constructor drops.
+ private static ProcessorRecordContext withHeaders(final
ProcessorRecordContext context, final Headers headers) {
+ if (context.headers() == headers) {
+ return context;
+ }
+ return new ProcessorRecordContext(
+ context.timestamp(),
+ context.offset(),
+ context.partition(),
+ context.topic(),
+ headers,
+ context.sourceRawKey(),
+ context.sourceRawValue()
+ );
+ }
+
@Override
public Maybe<ValueTimestampHeaders<V>> priorValueForBuffered(final K key) {
final Bytes serializedKey =
Bytes.wrap(keySerde.serializer().serialize(changelogTopic, context.headers(),
key));
- if (index.containsKey(serializedKey)) {
- final byte[] serializedValue =
internalPriorValueForBuffered(serializedKey);
+ final BufferKey bufferKey = index.get(serializedKey);
+ if (bufferKey != null) {
+ final BufferValue bufferValue = sortedMap.get(bufferKey);
+ final byte[] serializedValue = bufferValue.priorValue();
+
+ if (storeHeaders) {
+ // The prior value is stored as a ValueTimestampHeaders blob,
so we can recover its
+ // timestamp and headers directly (they are unknown/empty when
the key was first
+ // buffered, but preserved across restarts via the changelog).
+ return
Maybe.defined(deserializeValueTimestampHeaders(serializedValue));
+ }
final V deserializedValue =
valueSerde.innerSerde().deserializer().deserialize(
changelogTopic,
- context.headers(),
+ bufferValue.context().headers(),
serializedValue
);
@@ -465,6 +583,102 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
}
}
+ // 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.
The new value gets them
+ // from the restored record context, which describes exactly that part
(see put()); the prior and
+ // old values get them from the changelog record's own Kafka headers.
Records written before this
+ // feature -- or by a run configured without header stores -- carry no
such Kafka headers, in
+ // which case the prior/old 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 ProcessorRecordContext context = value.context();
+ final byte[] oldValue = valuePartWithHeaders(value.oldValue(),
recordHeaders, OLD_VALUE_HEADERS_KEY, context.timestamp());
+ // A missing vh.prior on a row whose prior and old values share an
array means the writer
+ // skipped the duplicate prefix, so the old part IS the prior part.
The two conditions have to
+ // be checked together: the changelog value dedups on the PLAIN bytes,
so a row can come back
+ // sharing an array even though the parts carried different headers
and were written with a
+ // vh.prior of their own. In that case the header wins.
+ final byte[] priorValue = value.priorValue() == value.oldValue()
+ && recordHeaders.lastHeader(PRIOR_VALUE_HEADERS_KEY) == null
+ ? oldValue
+ : valuePartWithHeaders(value.priorValue(), recordHeaders,
PRIOR_VALUE_HEADERS_KEY, context.timestamp());
+ return new BufferValue(
+ priorValue,
+ oldValue,
+ Utils.rawValueTimestampHeaders(value.newValue(),
context.timestamp(), context.headers()),
+ context
+ );
+ }
+
+ private static byte[] valuePartWithHeaders(final byte[] rawPlainValue,
+ final Headers recordHeaders,
+ final String headerKey,
+ final long fallbackTimestamp) {
+ if (rawPlainValue == null) {
+ return null;
+ }
+ final Header headersAndTimestamp = recordHeaders.lastHeader(headerKey);
+ return headersAndTimestamp == null
+ ? Utils.rawValueTimestampHeaders(rawPlainValue, fallbackTimestamp)
+ : Utils.rawValueTimestampHeaders(headersAndTimestamp.value(),
rawPlainValue);
+ }
+
+ 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> deserializeValueTimestampHeaders(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(deserializeValueTimestampHeaders(bytes));
+ }
+ return
valueSerde.innerSerde().deserializer().deserialize(changelogTopic,
fallbackHeaders, bytes);
+ }
+
@Override
public boolean put(final long time,
final Record<K, Change<V>> record,
@@ -472,21 +686,44 @@ public final class
InMemoryTimeOrderedKeyValueChangeBuffer<K, V, T> implements T
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 headers of the record being processed. These describe the new
value: the old value came
+ // from an earlier record and gets its own headers below. The
framework keeps recordContext in
+ // sync with the record (see StreamTask#doProcess and
ProcessorContextImpl#forward), so this is
+ // the same object as recordContext.headers(); we read it off the
record because that is where
+ // it conceptually belongs.
+ final Headers headers = 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 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 =
deserializeValueTimestampHeaders(buffered.newValue());
+ if (previousNewValue != null) {
+ oldHeaders = previousNewValue.headers();
+ oldTimestamp = previousNewValue.timestamp();
+ }
}
+ final Change<V> change = record.value();
+ // Order matters, and must stay old-then-new as in
FullChangeSerde#serializeParts: a serializer
+ // may write into the headers it is handed (Schema Registry serdes
record the schema id there),
+ // and in plain mode both parts are serialized against the same live
record headers, so
+ // whichever is serialized last determines what the emitted record
carries. That has to be the
+ // new value. In plain mode the old value is therefore serialized with
the current record's headers
+ // exactly as before, leaving the stored bytes unchanged for
non-header stores.
+ final byte[] oldValue = serializeValuePart(change.oldValue,
oldTimestamp, storeHeaders ? oldHeaders : headers);
+ final byte[] newValue = serializeValuePart(change.newValue, timestamp,
headers);
+ final byte[] serializedPriorValue = buffered == null ? oldValue :
buffered.priorValue();
+
cleanPut(
time,
serializedKey,
- new BufferValue(serializedPriorValue, serialChange.oldValue,
serialChange.newValue, recordContext)
+ new BufferValue(serializedPriorValue, oldValue, newValue,
recordContext)
);
if (loggingEnabled) {
dirtyKeys.add(serializedKey);
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/Utils.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/Utils.java
index 6b9d3827587..6c899847641 100644
--- a/streams/src/main/java/org/apache/kafka/streams/state/internals/Utils.java
+++ b/streams/src/main/java/org/apache/kafka/streams/state/internals/Utils.java
@@ -107,6 +107,89 @@ public class Utils {
return result;
}
+ /**
+ * Build a serialized ValueTimestampHeaders with empty headers from a
plain value and a timestamp.
+ * This is the inverse of {@link #rawPlainValue(byte[])} for the
empty-headers case.
+ *
+ * Format conversion:
+ * Input: [value], timestamp
+ * Output: [headersSize(varint)=0][timestamp(8)][value]
+ */
+ public static byte[] rawValueTimestampHeaders(final byte[] rawPlainValue,
final long timestamp) {
+ return rawValueTimestampHeaders(rawPlainValue, timestamp, null);
+ }
+
+ /**
+ * Build a serialized ValueTimestampHeaders from a plain value, a
timestamp and headers.
+ * This is the inverse of {@link #rawPlainValue(byte[])}.
+ *
+ * Format conversion:
+ * Input: [value], timestamp, headers
+ * Output: [headersSize(varint)][headers][timestamp(8)][value]
+ */
+ public static byte[] rawValueTimestampHeaders(final byte[] rawPlainValue,
+ final long timestamp,
+ final Headers headers) {
+ if (rawPlainValue == null) {
+ return null;
+ }
+
+ final HeadersSerializer.PreSerializedHeaders preSerializedHeaders =
HeadersSerializer.prepareSerialization(headers);
+ final ByteBuffer buffer = ByteBuffer.allocate(
+
ByteUtils.sizeOfVarint(preSerializedHeaders.requiredBufferSizeForHeaders)
+ + preSerializedHeaders.requiredBufferSizeForHeaders
+ + StateSerdes.TIMESTAMP_SIZE
+ + rawPlainValue.length);
+
ByteUtils.writeVarint(preSerializedHeaders.requiredBufferSizeForHeaders,
buffer);
+ return HeadersSerializer.serialize(preSerializedHeaders, buffer)
+ .putLong(timestamp)
+ .put(rawPlainValue)
+ .array();
+ }
+
+ /**
+ * Extract the headers-and-timestamp prefix of a serialized
ValueTimestampHeaders, i.e. exactly
+ * the part that {@link #rawPlainValue(byte[])} strips off. Splitting a
value this way lets the
+ * plain value bytes and their headers/timestamp be stored or transmitted
separately and then
+ * recombined with {@link #rawValueTimestampHeaders(byte[], byte[])}.
+ *
+ * Format conversion:
+ * Input: [headersSize(varint)][headers][timestamp(8)][value]
+ * Output: [headersSize(varint)][headers][timestamp(8)]
+ */
+ public static byte[] rawHeadersTimestampPrefix(final byte[]
rawValueTimestampHeaders) {
+ if (rawValueTimestampHeaders == null) {
+ return null;
+ }
+
+ final ByteBuffer buffer = ByteBuffer.wrap(rawValueTimestampHeaders);
+ final int headersSize = ByteUtils.readVarint(buffer);
+ final int prefixLength = buffer.position() + headersSize +
StateSerdes.TIMESTAMP_SIZE;
+
+ final byte[] prefix = new byte[prefixLength];
+ System.arraycopy(rawValueTimestampHeaders, 0, prefix, 0, prefixLength);
+ return prefix;
+ }
+
+ /**
+ * Rebuild a serialized ValueTimestampHeaders from a prefix produced by
+ * {@link #rawHeadersTimestampPrefix(byte[])} and the plain value bytes it
was split from.
+ *
+ * Format conversion:
+ * Input: [headersSize(varint)][headers][timestamp(8)], [value]
+ * Output: [headersSize(varint)][headers][timestamp(8)][value]
+ */
+ public static byte[] rawValueTimestampHeaders(final byte[]
rawHeadersTimestampPrefix, final byte[] rawPlainValue) {
+ if (rawPlainValue == null) {
+ return null;
+ }
+
+ final byte[] result = new byte[rawHeadersTimestampPrefix.length +
rawPlainValue.length];
+ System.arraycopy(rawHeadersTimestampPrefix, 0, result, 0,
rawHeadersTimestampPrefix.length);
+ System.arraycopy(rawPlainValue, 0, result,
rawHeadersTimestampPrefix.length, rawPlainValue.length);
+ return result;
+ }
+
/**
* Extract raw timestamped value (timestamp + value) from serialized
ValueTimestampHeaders.
* This strips the headers portion but keeps timestamp and value intact.
diff --git
a/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
b/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
index d31d9880622..3f17d275c13 100644
---
a/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/kstream/internals/SuppressHeadersScenarioTest.java
@@ -47,7 +47,6 @@ import org.apache.kafka.test.TestUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
@@ -115,10 +114,14 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
* INV-1 is therefore unsatisfiable in plain format for these scenarios, no
matter how
* {@code suppress()} is implemented.
*
- * <p>{@link #nonWindowedEvictionTriggeredBySameKey()} and
- * {@link #windowedEvictionTriggeredBySameKeySameWindow()} consequently set
+ * <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.
+ * 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.
@@ -164,7 +167,6 @@ public class SuppressHeadersScenarioTest {
// ------------------------------------------------------------------
scenarios
- @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
@Test
public void nonWindowedEvictionTriggeredByDifferentKey() {
scenario = "non-windowed / eviction triggered by a DIFFERENT key";
@@ -187,7 +189,6 @@ public class SuppressHeadersScenarioTest {
* The row holds {@code new=v2 / old=v1}, each from a different input
record, which plain values
* cannot represent.
*/
- @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
@Test
public void nonWindowedEvictionTriggeredBySameKey() {
scenario = "non-windowed / SAME key, row updated then evicted (headers
format)";
@@ -218,10 +219,13 @@ public class SuppressHeadersScenarioTest {
* emitted; with a tombstone only the <em>old</em> value is serialized,
which makes the leak
* visible on the emitted record.
*/
- @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
@Test
public void nonWindowedTombstoneMustBeForwarded() {
- scenario = "non-windowed / buffered row is DELETED, tombstone must be
forwarded";
+ scenario = "non-windowed / buffered row is DELETED, tombstone must be
forwarded (headers format)";
+ // Same-row overwrite: the delete lands on the row it deletes, so the
row still holds an old
+ // value from the earlier record. INV-1 therefore needs two origins
recoverable from one row;
+ // see the class javadoc on why that requires the HEADERS format.
+ config.setProperty(StreamsConfig.DSL_STORE_FORMAT_CONFIG,
StreamsConfig.DSL_STORE_FORMAT_HEADERS);
try (final TopologyTestDriver driver = new
TopologyTestDriverBuilder(nonWindowedTopology()).withConfig(config).build()) {
final TestInputTopic<String, String> input =
driver.createInputTopic(INPUT_TOPIC, new StringSerializer(),
new StringSerializer());
@@ -242,7 +246,6 @@ public class SuppressHeadersScenarioTest {
* it records: a <em>same</em> input key still produces a
<em>different</em> suppress row once
* windowed, so the evicted row is not the one the arriving record updated.
*/
- @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
@Test
public void windowedEvictionTriggeredBySameKeyDifferentWindow() {
scenario = "windowed (untilWindowCloses) / SAME key but a DIFFERENT
window";
@@ -265,7 +268,6 @@ public class SuppressHeadersScenarioTest {
* under {@code untilTimeLimit}. It cannot happen under {@code
untilWindowCloses}, where being
* inside a window excludes being past its close.
*/
- @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
@Test
public void windowedEvictionTriggeredBySameKeySameWindow() {
scenario = "windowed (untilTimeLimit) / SAME key and SAME window
(headers format)";
@@ -296,10 +298,12 @@ public class SuppressHeadersScenarioTest {
* <p>A null value cannot be piped into an aggregation, so the tombstone
is staged by having the
* reducer return {@code null} for the sentinel value {@code DELETE}.
*/
- @Disabled("Enabled by the fix for KAFKA-20413; see class javadoc")
@Test
public void windowedFinalResultsTombstoneIsDropped() {
- scenario = "windowed (untilWindowCloses) / tombstone is dropped by
design";
+ scenario = "windowed (untilWindowCloses) / tombstone is dropped by
design (headers format)";
+ // Same-row overwrite as above (kA's aggregate is updated to null), so
this too needs the
+ // HEADERS format for INV-1 to be satisfiable at all.
+ config.setProperty(StreamsConfig.DSL_STORE_FORMAT_CONFIG,
StreamsConfig.DSL_STORE_FORMAT_HEADERS);
try (final TopologyTestDriver driver = new
TopologyTestDriverBuilder(windowedTopology(false)).withConfig(config).build()) {
final TestInputTopic<String, String> input =
driver.createInputTopic(INPUT_TOPIC, new StringSerializer(),
new StringSerializer());
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimeOrderedKeyValueBufferTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimeOrderedKeyValueBufferTest.java
index 3efbfdf5b53..f944ec23e81 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimeOrderedKeyValueBufferTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimeOrderedKeyValueBufferTest.java
@@ -19,12 +19,17 @@ package org.apache.kafka.streams.state.internals;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.header.Header;
+import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.apache.kafka.common.record.TimestampType;
+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.common.utils.Utils;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.StreamsConfig;
@@ -33,18 +38,21 @@ import org.apache.kafka.streams.processor.TaskId;
import org.apache.kafka.streams.processor.api.Record;
import org.apache.kafka.streams.processor.internals.ProcessorRecordContext;
import
org.apache.kafka.streams.processor.internals.RecordBatchingStateRestoreCallback;
+import org.apache.kafka.streams.processor.internals.RecordQueue;
import org.apache.kafka.streams.state.ValueTimestampHeaders;
import
org.apache.kafka.streams.state.internals.TimeOrderedKeyValueBuffer.Eviction;
import org.apache.kafka.test.MockInternalProcessorContext;
import org.apache.kafka.test.MockRecordCollector;
import org.apache.kafka.test.TestUtils;
+import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.IOException;
import java.nio.ByteBuffer;
+import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
@@ -60,13 +68,20 @@ import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static
org.apache.kafka.streams.state.internals.InMemoryTimeOrderedKeyValueChangeBuffer.CHANGELOG_HEADERS;
+import static
org.apache.kafka.streams.state.internals.InMemoryTimeOrderedKeyValueChangeBuffer.OLD_VALUE_HEADERS_KEY;
+import static
org.apache.kafka.streams.state.internals.InMemoryTimeOrderedKeyValueChangeBuffer.PRIOR_VALUE_HEADERS_KEY;
+import static
org.apache.kafka.streams.state.internals.Utils.rawValueTimestampHeaders;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.not;
+import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.fail;
public class TimeOrderedKeyValueBufferTest<B extends
TimeOrderedKeyValueBuffer<String, String, Change<String>>> {
private static final String APP_ID = "test-app";
+ /** Store name for the tests that build a buffer directly rather than
through {@link #parameters()}. */
+ private static final String STORE_NAME = "test-buffer";
private Function<String, B> bufferSupplier;
private String testName;
@@ -97,9 +112,16 @@ public class TimeOrderedKeyValueBufferTest<B extends
TimeOrderedKeyValueBuffer<S
}
private static MockInternalProcessorContext<?, ?> makeContext() {
+ return makeContext(false);
+ }
+
+ private static MockInternalProcessorContext<?, ?> makeContext(final
boolean headersEnabled) {
final Properties properties = new Properties();
properties.setProperty(StreamsConfig.APPLICATION_ID_CONFIG, APP_ID);
properties.setProperty(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG,
"mock:localhost:9092");
+ if (headersEnabled) {
+ properties.setProperty(StreamsConfig.DSL_STORE_FORMAT_CONFIG,
StreamsConfig.DSL_STORE_FORMAT_HEADERS);
+ }
final TaskId taskId = new TaskId(0, 0);
@@ -110,6 +132,19 @@ public class TimeOrderedKeyValueBufferTest<B extends
TimeOrderedKeyValueBuffer<S
}
+ /** Replays everything the source context's collector captured into the
restore context's callback. */
+ private static void restoreInto(final MockInternalProcessorContext<?, ?>
restoreContext,
+ final MockInternalProcessorContext<?, ?>
sourceContext,
+ final String storeName) {
+ final List<ConsumerRecord<byte[], byte[]>> toRestore = new
LinkedList<>();
+ for (final ProducerRecord<Object, Object> pr : ((MockRecordCollector)
sourceContext.recordCollector()).collected()) {
+ toRestore.add(new ConsumerRecord<>(
+ "changelog-topic", 0, 0, 999, TimestampType.CREATE_TIME, -1,
-1,
+ ((Bytes) pr.key()).get(), (byte[]) pr.value(), pr.headers(),
Optional.empty()));
+ }
+ ((RecordBatchingStateRestoreCallback)
restoreContext.stateRestoreCallback(storeName)).restoreBatch(toRestore);
+ }
+
private static void cleanup(final MockInternalProcessorContext<?, ?>
context, final TimeOrderedKeyValueBuffer<String, String, Change<String>>
buffer) {
try {
buffer.close();
@@ -310,6 +345,568 @@ public class TimeOrderedKeyValueBufferTest<B extends
TimeOrderedKeyValueBuffer<S
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))});
+ // The framework keeps the record context in sync with the record
being processed
+ // (StreamTask#doProcess, ProcessorContextImpl#forward), so both carry
the same headers here.
+ final ProcessorRecordContext recordContext = new
ProcessorRecordContext(0L, 0, 0, "topic", headers);
+ context.setRecordContext(recordContext);
+ buffer.put(0L, new Record<>("k", new Change<>("v", null), 0L,
headers), recordContext);
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ buffer.evictWhile(() -> true, evicted::add);
+
+ assertThat(evicted.size(), is(1));
+ assertThat(evicted.get(0).recordContext().headers(), is(headers));
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void
shouldDeserializeEachValuePartWithItsOwnHeadersWhenHeadersEnabled() {
+ // In headers mode the old and the new value of a buffered row
originate from two different
+ // input records, so each must be handed the headers of the record it
came from. A
+ // header-dependent deserializer (as e.g. Schema Registry serdes are,
and String serdes are
+ // not) records which headers it actually sees.
+ final List<String> headerSeenByDeserializer = new ArrayList<>();
+ final Deserializer<String> recordingDeserializer = new
Deserializer<>() {
+ @Override
+ public String deserialize(final String topic, final byte[] data) {
+ return data == null ? null : new String(data, UTF_8);
+ }
+
+ @Override
+ public String deserialize(final String topic, final Headers
headers, final byte[] data) {
+ final Header header = headers.lastHeader("h");
+ headerSeenByDeserializer.add(header == null ? "none" : new
String(header.value(), UTF_8));
+ return deserialize(topic, data);
+ }
+ };
+
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(
+ "test-buffer", Serdes.String(), Serdes.serdeFrom(new
StringSerializer(), recordingDeserializer)).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headersA = new RecordHeaders(new Header[]{new
RecordHeader("h", "A".getBytes(UTF_8))});
+ final RecordHeaders headersB = new RecordHeaders(new Header[]{new
RecordHeader("h", "B".getBytes(UTF_8))});
+
+ // Record 1 (headers A) first buffers "k"="v1".
+ final ProcessorRecordContext contextA = new
ProcessorRecordContext(10L, 0, 0, "topic", headersA);
+ context.setRecordContext(contextA);
+ buffer.put(0L, new Record<>("k", new Change<>("v1", null), 10L,
headersA), contextA);
+
+ // Record 2 (headers B) updates "k" in place, so "v1" becomes the old
value of the row while
+ // the new value "v2" belongs to record 2.
+ final ProcessorRecordContext contextB = new
ProcessorRecordContext(20L, 1, 0, "topic", headersB);
+ context.setRecordContext(contextB);
+ buffer.put(0L, new Record<>("k", new Change<>("v2", "v1"), 20L,
headersB), contextB);
+
+ // The second put reads back the previous new value to recover the old
value's headers; only
+ // the eviction is under test here.
+ headerSeenByDeserializer.clear();
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ buffer.evictWhile(() -> true, evicted::add);
+
+ assertThat(evicted.size(), is(1));
+ assertThat(evicted.get(0).value(), is(new Change<>("v2", "v1")));
+ // New value first with its own headers (B), then the old value with
the headers of the record
+ // it originally arrived on (A) -- not with B, and not with whatever
triggered the eviction.
+ assertThat(headerSeenByDeserializer, is(List.of("B", "A")));
+ // The emitted record carries the new value's headers.
+ assertThat(evicted.get(0).recordContext().headers(), is(headersB));
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void
shouldDeserializeEvictedValueWithBufferedHeadersNotEvictionTriggerHeaders() {
+ // A header-dependent value deserializer (as e.g. Schema Registry
serdes are, and String
+ // serdes are not) records which headers it is handed. This lets us
prove that on eviction the
+ // buffered value is deserialized with the headers it was buffered
with, and not with the
+ // headers of whatever record happened to trigger the eviction.
+ final List<String> headerSeenByDeserializer = new ArrayList<>();
+ final Deserializer<String> recordingDeserializer = new
Deserializer<>() {
+ @Override
+ public String deserialize(final String topic, final byte[] data) {
+ return data == null ? null : new String(data, UTF_8);
+ }
+
+ @Override
+ public String deserialize(final String topic, final Headers
headers, final byte[] data) {
+ final Header header = headers.lastHeader("h");
+ headerSeenByDeserializer.add(header == null ? "none" : new
String(header.value(), UTF_8));
+ return deserialize(topic, data);
+ }
+ };
+ final Serde<String> valueSerde = Serdes.serdeFrom(new
StringSerializer(), recordingDeserializer);
+
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new
InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>("test-buffer",
Serdes.String(), valueSerde).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext();
+ buffer.init(context, buffer);
+
+ // Buffer key "k" while the processing context carries header h=A.
+ final RecordHeaders bufferedHeaders = new RecordHeaders(new
Header[]{new RecordHeader("h", "A".getBytes(UTF_8))});
+ final ProcessorRecordContext bufferedContext = new
ProcessorRecordContext(0L, 0, 0, "topic", bufferedHeaders);
+ context.setRecordContext(bufferedContext);
+ buffer.put(0L, new Record<>("k", new Change<>("v", null), 0L,
bufferedHeaders), bufferedContext);
+
+ // Eviction happens later, while a DIFFERENT record (header h=B) is
being processed.
+ context.setRecordContext(new ProcessorRecordContext(1L, 1, 0, "topic",
+ new RecordHeaders(new Header[]{new RecordHeader("h",
"B".getBytes(UTF_8))})));
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ buffer.evictWhile(() -> true, evicted::add);
+
+ assertThat(evicted.size(), is(1));
+ // The buffered value must be deserialized with its own headers ("A"),
not the headers of the
+ // record that triggered the eviction ("B").
+ assertThat(headerSeenByDeserializer, is(singletonList("A")));
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void shouldSerializeNewValueLastSoItsHeadersWin() {
+ // A serializer may write into the headers it is handed (Schema
Registry serdes record the
+ // schema id there). In plain mode both value parts are serialized
against the same live record
+ // headers, so the part serialized LAST determines what the emitted
record carries -- and that
+ // has to be the new value. This is why FullChangeSerde#serializeParts
serializes old before new.
+ final Serializer<String> headerWritingSerializer = new Serializer<>() {
+ @Override
+ public byte[] serialize(final String topic, final String data) {
+ return data == null ? null : data.getBytes(UTF_8);
+ }
+
+ @Override
+ public byte[] serialize(final String topic, final Headers headers,
final String data) {
+ headers.add(new RecordHeader("serialized",
data.getBytes(UTF_8)));
+ return serialize(topic, data);
+ }
+ };
+ final Serde<String> valueSerde =
Serdes.serdeFrom(headerWritingSerializer, new StringDeserializer());
+
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new
InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>("test-buffer",
Serdes.String(), valueSerde).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext();
+ buffer.init(context, buffer);
+
+ // Record's constructor copies the headers it is given, so build the
context from the record's
+ // own headers object -- that is the object the framework ends up
sharing between the two
+ // (ProcessorContextImpl#forward re-points the context at
record.headers()), and the one that
+ // gets forwarded downstream.
+ final Record<String, Change<String>> record =
+ new Record<>("k", new Change<>("new", "old"), 0L, new
RecordHeaders());
+ final ProcessorRecordContext recordContext = new
ProcessorRecordContext(0L, 0, 0, "topic", record.headers());
+ context.setRecordContext(recordContext);
+ buffer.put(0L, record, recordContext);
+
+ assertThat(new
String(record.headers().lastHeader("serialized").value(), UTF_8), is("new"));
+
+ // A tombstone has no new value to serialize, so the old value's
header is the one that stands.
+ final Record<String, Change<String>> tombstone =
+ new Record<>("k2", new Change<>(null, "old"), 1L, new
RecordHeaders());
+ final ProcessorRecordContext tombstoneContext = new
ProcessorRecordContext(1L, 1, 0, "topic", tombstone.headers());
+ context.setRecordContext(tombstoneContext);
+ buffer.put(0L, tombstone, tombstoneContext);
+
+ assertThat(new
String(tombstone.headers().lastHeader("serialized").value(), UTF_8), is("old"));
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void shouldStorePerValueHeadersInChangelogWhenHeadersEnabled() {
+ // With dsl.store.format=HEADERS the old and new value parts each
carry their OWN headers and
+ // timestamp. Those must NOT go into the changelog value -- that has
to stay in the V3 format
+ // older versions can restore -- so they travel in the changelog
record's Kafka headers.
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new
InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>("test-buffer",
Serdes.String(), Serdes.String()).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headersA = new RecordHeaders(new Header[]{new
RecordHeader("h", "A".getBytes(UTF_8))});
+ final RecordHeaders headersB = new RecordHeaders(new Header[]{new
RecordHeader("h", "B".getBytes(UTF_8))});
+
+ // First buffer "k" (value "v1") with headers A at record timestamp 10.
+ final ProcessorRecordContext contextA = new
ProcessorRecordContext(10L, 0, 0, "topic", headersA);
+ context.setRecordContext(contextA);
+ buffer.put(0L, new Record<>("k", new Change<>("v1", null), 10L,
headersA), contextA);
+
+ // In-place update (value "v2", old "v1") with headers B at record
timestamp 20. The old value
+ // ("v1") should keep the first record's headers/timestamp (A / 10)
via carry-forward.
+ final ProcessorRecordContext contextB = new
ProcessorRecordContext(20L, 1, 0, "topic", headersB);
+ context.setRecordContext(contextB);
+ buffer.put(0L, new Record<>("k", new Change<>("v2", "v1"), 20L,
headersB), contextB);
+
+ buffer.commit(Map.of());
+
+ final List<ProducerRecord<Object, Object>> collected =
((MockRecordCollector) context.recordCollector()).collected();
+ assertThat(collected.size(), is(1));
+ final ProducerRecord<Object, Object> changelogRecord =
collected.get(0);
+
+ // The version marker stays at V3, so an older version restores this
record fine...
+ assertThat(changelogRecord.headers().lastHeader("v").value(), is(new
byte[] {(byte) 3}));
+
+ // ...because the value bytes are plain values, exactly as the V3
format prescribes.
+ final BufferValue bufferValue =
BufferValue.deserialize(ByteBuffer.wrap((byte[]) changelogRecord.value()));
+ final StringDeserializer plainDeserializer = new StringDeserializer();
+ assertThat(plainDeserializer.deserialize("topic",
bufferValue.newValue()), is("v2"));
+ assertThat(plainDeserializer.deserialize("topic",
bufferValue.oldValue()), is("v1"));
+
+ // The new value's headers and timestamp need no Kafka header of their
own: the record context
+ // encoded in the V3 value already describes that part, since it is
the context of the very
+ // record the new value came from.
+ assertThat(bufferValue.context().headers(), is(headersB));
+ assertThat(bufferValue.context().timestamp(), is(20L));
+
+ // The prior and old parts have no such carrier, so their headers and
timestamps ride in the
+ // record's Kafka headers, and recombine with the plain value bytes
into the in-memory encoding.
+ final ValueTimestampHeadersDeserializer<String> deserializer =
+ new ValueTimestampHeadersDeserializer<>(new StringDeserializer());
+
+ final ValueTimestampHeaders<String> oldValue =
deserializer.deserialize("topic",
+
rawValueTimestampHeaders(changelogRecord.headers().lastHeader(OLD_VALUE_HEADERS_KEY).value(),
bufferValue.oldValue()));
+ assertThat(oldValue.value(), is("v1"));
+ assertThat(oldValue.timestamp(), is(10L)); // carried forward from
the first record
+ assertThat(oldValue.headers(), is(headersA)); // carried forward from
the first record
+
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void
shouldNotWritePriorValueHeadersWhenPriorAndOldValueShareAnArray() {
+ // On the first buffering of a key the prior value IS the old value:
BufferValue collapses them
+ // onto one array and the V3 serialization writes those bytes only
once. The per-part headers
+ // must not undo that saving by writing the same prefix under a second
key.
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(STORE_NAME,
Serdes.String(), Serdes.String()).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h", "A".getBytes(UTF_8))});
+ final ProcessorRecordContext recordContext = new
ProcessorRecordContext(10L, 0, 0, "topic", headers);
+ context.setRecordContext(recordContext);
+ buffer.put(0L, new Record<>("k", new Change<>("v1", "p"), 10L,
headers), recordContext);
+ buffer.commit(Map.of());
+
+ final ProducerRecord<Object, Object> changelogRecord =
+ ((MockRecordCollector)
context.recordCollector()).collected().get(0);
+
assertThat(changelogRecord.headers().lastHeader(OLD_VALUE_HEADERS_KEY),
is(not(nullValue())));
+
assertThat(changelogRecord.headers().lastHeader(PRIOR_VALUE_HEADERS_KEY),
is(nullValue()));
+
+ // The prior value must still come back, recovered from the old part
rather than from the
+ // header the writer deliberately left out.
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> restored =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(STORE_NAME,
Serdes.String(), Serdes.String()).build();
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(true);
+ restored.init(restoreContext, restored);
+ restoreInto(restoreContext, context, STORE_NAME);
+
+ assertThat(restored.priorValueForBuffered("k"),
+ is(Maybe.defined(ValueTimestampHeaders.make("p",
RecordQueue.UNKNOWN, new RecordHeaders()))));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void
shouldKeepPriorValueHeadersWhenOnlyThePlainBytesOfPriorAndOldValueMatch() {
+ // The counter-case that makes the dedup above non-trivial: the
changelog value dedups on the
+ // PLAIN bytes, so a row can come back from the changelog sharing an
array even though its prior
+ // and old parts carried different headers and timestamps. Here the
old value is written a second
+ // time with the same value bytes ("p") but picks up the first
record's headers by carry-forward,
+ // while the prior value keeps the unknown/empty origin it was first
buffered with.
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(STORE_NAME,
Serdes.String(), Serdes.String()).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headersA = new RecordHeaders(new Header[]{new
RecordHeader("h", "A".getBytes(UTF_8))});
+ final RecordHeaders headersB = new RecordHeaders(new Header[]{new
RecordHeader("h", "B".getBytes(UTF_8))});
+
+ final ProcessorRecordContext contextA = new
ProcessorRecordContext(10L, 0, 0, "topic", headersA);
+ context.setRecordContext(contextA);
+ buffer.put(0L, new Record<>("k", new Change<>("v1", "p"), 10L,
headersA), contextA);
+
+ final ProcessorRecordContext contextB = new
ProcessorRecordContext(20L, 1, 0, "topic", headersB);
+ context.setRecordContext(contextB);
+ buffer.put(0L, new Record<>("k", new Change<>("v2", "p"), 20L,
headersB), contextB);
+
+ buffer.commit(Map.of());
+
+ // The parts differ, so this time the prefix must be written under its
own key.
+ final ProducerRecord<Object, Object> changelogRecord =
+ ((MockRecordCollector)
context.recordCollector()).collected().get(0);
+
assertThat(changelogRecord.headers().lastHeader(PRIOR_VALUE_HEADERS_KEY),
is(not(nullValue())));
+
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> restored =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(STORE_NAME,
Serdes.String(), Serdes.String()).build();
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(true);
+ restored.init(restoreContext, restored);
+ restoreInto(restoreContext, context, STORE_NAME);
+
+ // Not (p, 10, A) -- that is the OLD part, and taking it here would be
the dedup misfiring.
+ assertThat(restored.priorValueForBuffered("k"),
+ is(Maybe.defined(ValueTimestampHeaders.make("p",
RecordQueue.UNKNOWN, new RecordHeaders()))));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void
shouldPreservePriorValueTimestampAndHeadersWhenHeadersEnabled(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(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h1", "v1".getBytes(UTF_8))});
+ final ProcessorRecordContext recordContext = getContext(0L);
+ context.setRecordContext(recordContext);
+ buffer.put(1L, new Record<>("A", new Change<>("new-value",
"old-value"), 0L, headers), recordContext);
+ buffer.put(1L, new Record<>("B", new Change<>("new-value", null), 0L,
headers), recordContext);
+
+ // The prior value's original timestamp/headers are unknown when a key
is first buffered, so
+ // they round-trip through the ValueTimestampHeaders encoding as
UNKNOWN/empty.
+ assertThat(buffer.priorValueForBuffered("A"),
is(Maybe.defined(ValueTimestampHeaders.make("old-value", -1, new
RecordHeaders()))));
+ assertThat(buffer.priorValueForBuffered("B"), is(Maybe.defined(null)));
+ cleanup(context, buffer);
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void
shouldRoundTripHeadersThroughCommitAndRestoreWhenHeadersEnabled(final String
testName, final Function<String, B> bufferSupplier) {
+ setup(testName, bufferSupplier);
+
+ // Buffer a record (with headers and a record timestamp distinct from
the buffer time) and
+ // commit it to the changelog.
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>> buffer
= bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h1", "v1".getBytes(UTF_8))});
+ context.setRecordContext(new ProcessorRecordContext(5L, 0, 0, "topic",
headers));
+ buffer.put(0L, new Record<>("k", new Change<>("new", "old"), 5L,
headers), context.recordContext());
+ buffer.commit(Map.of());
+
+ final List<ProducerRecord<Object, Object>> collected =
((MockRecordCollector) context.recordCollector()).collected();
+ assertThat(collected.size(), is(1));
+
+ // Restore the changelog into a fresh buffer and confirm the value,
record timestamp and
+ // headers all survived the serialization round-trip.
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>>
restored = bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(true);
+ restored.init(restoreContext, restored);
+ final RecordBatchingStateRestoreCallback stateRestoreCallback =
+ (RecordBatchingStateRestoreCallback)
restoreContext.stateRestoreCallback(testName);
+
+ final List<ConsumerRecord<byte[], byte[]>> toRestore = new
LinkedList<>();
+ for (final ProducerRecord<Object, Object> pr : collected) {
+ toRestore.add(new ConsumerRecord<>(
+ "changelog-topic", 0, 0, 999, TimestampType.CREATE_TIME, -1,
-1,
+ ((Bytes) pr.key()).get(), (byte[]) pr.value(), pr.headers(),
Optional.empty()));
+ }
+ stateRestoreCallback.restoreBatch(toRestore);
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ restored.evictWhile(() -> true, evicted::add);
+
+ assertThat(evicted.size(), is(1));
+ assertThat(evicted.get(0).key(), is("k"));
+ assertThat(evicted.get(0).value(), is(new Change<>("new", "old")));
+ assertThat(evicted.get(0).recordContext().timestamp(), is(5L));
+ assertThat(evicted.get(0).recordContext().headers(), is(headers));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
+ @Test
+ public void
shouldRoundTripDistinctPerValuePartHeadersThroughCommitAndRestore() {
+ // The point of the per-part changelog headers: a row whose old and
new value come from
+ // different records must come back from the changelog with BOTH
origins intact, not just one.
+ // A recording deserializer shows what each part is actually handed
after the restore.
+ final List<String> headerSeenByDeserializer = new ArrayList<>();
+ final Deserializer<String> recordingDeserializer = new
Deserializer<>() {
+ @Override
+ public String deserialize(final String topic, final byte[] data) {
+ return data == null ? null : new String(data, UTF_8);
+ }
+
+ @Override
+ public String deserialize(final String topic, final Headers
headers, final byte[] data) {
+ final Header header = headers.lastHeader("h");
+ headerSeenByDeserializer.add(header == null ? "none" : new
String(header.value(), UTF_8));
+ return deserialize(topic, data);
+ }
+ };
+ final Serde<String> valueSerde = Serdes.serdeFrom(new
StringSerializer(), recordingDeserializer);
+
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> buffer =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(STORE_NAME,
Serdes.String(), valueSerde).build();
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headersA = new RecordHeaders(new Header[]{new
RecordHeader("h", "A".getBytes(UTF_8))});
+ final RecordHeaders headersB = new RecordHeaders(new Header[]{new
RecordHeader("h", "B".getBytes(UTF_8))});
+
+ final ProcessorRecordContext contextA = new
ProcessorRecordContext(10L, 0, 0, "topic", headersA);
+ context.setRecordContext(contextA);
+ buffer.put(0L, new Record<>("k", new Change<>("v1", null), 10L,
headersA), contextA);
+
+ final ProcessorRecordContext contextB = new
ProcessorRecordContext(20L, 1, 0, "topic", headersB);
+ context.setRecordContext(contextB);
+ buffer.put(0L, new Record<>("k", new Change<>("v2", "v1"), 20L,
headersB), contextB);
+
+ buffer.commit(Map.of());
+
+ final InMemoryTimeOrderedKeyValueChangeBuffer<String, String,
Change<String>> restored =
+ new InMemoryTimeOrderedKeyValueChangeBuffer.Builder<>(STORE_NAME,
Serdes.String(), valueSerde).build();
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(true);
+ restored.init(restoreContext, restored);
+ restoreInto(restoreContext, context, STORE_NAME);
+
+ // Only the eviction of the restored buffer is under test.
+ headerSeenByDeserializer.clear();
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ restored.evictWhile(() -> true, evicted::add);
+
+ assertThat(evicted.size(), is(1));
+ assertThat(evicted.get(0).value(), is(new Change<>("v2", "v1")));
+ // New value with its own headers (B), then the old value with the
headers of the record it
+ // originally arrived on (A) -- both recovered from the changelog, not
just the latest one.
+ assertThat(headerSeenByDeserializer, is(List.of("B", "A")));
+ assertThat(evicted.get(0).recordContext().headers(), is(headersB));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void
shouldRestoreChangelogWrittenWithoutHeadersIntoBufferWithHeaders(final String
testName, final Function<String, B> bufferSupplier) {
+ setup(testName, bufferSupplier);
+
+ // The upgrade path, and the mirror of the downgrade test below: a
changelog written before
+ // this feature (or by a run without dsl.store.format=HEADERS) carries
no per-part headers.
+ // The new value still recovers its own headers and timestamp from the
encoded record context,
+ // but the prior and old originals are genuinely unknown and fall back
to empty headers with
+ // the record-context timestamp. The values themselves must still
restore intact.
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>> buffer
= bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> context = makeContext(false);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h1", "v1".getBytes(UTF_8))});
+ context.setRecordContext(new ProcessorRecordContext(5L, 0, 0, "topic",
headers));
+ buffer.put(0L, new Record<>("k", new Change<>("new", "old"), 5L,
headers), context.recordContext());
+ buffer.commit(Map.of());
+
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>>
restored = bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(true);
+ restored.init(restoreContext, restored);
+ restoreInto(restoreContext, context, testName);
+
+ // The prior value has no per-part headers to recover, so it falls
back to empty headers and
+ // the record-context timestamp rather than the UNKNOWN it would carry
in a headers changelog.
+ assertThat(restored.priorValueForBuffered("k"),
is(Maybe.defined(ValueTimestampHeaders.make("old", 5L, new RecordHeaders()))));
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ restored.evictWhile(() -> true, evicted::add);
+
+ assertThat(evicted.size(), is(1));
+ assertThat(evicted.get(0).key(), is("k"));
+ assertThat(evicted.get(0).value(), is(new Change<>("new", "old")));
+ assertThat(evicted.get(0).recordContext().timestamp(), is(5L));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void
shouldPreservePriorValueTimestampAndHeadersAcrossRestoreWhenHeadersEnabled(final
String testName, final Function<String, B> bufferSupplier) {
+ setup(testName, bufferSupplier);
+
+ // vh.prior exists so the prior value's headers and timestamp survive
the changelog; that value
+ // is surfaced to downstream value getters, so it has to come back
exactly as it went in. On a
+ // first insert they are genuinely unknown, which must round-trip as
UNKNOWN/empty rather than
+ // silently becoming the record-context timestamp.
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>> buffer
= bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h1", "v1".getBytes(UTF_8))});
+ context.setRecordContext(new ProcessorRecordContext(5L, 0, 0, "topic",
headers));
+ buffer.put(0L, new Record<>("k", new Change<>("new", "old"), 5L,
headers), context.recordContext());
+ buffer.commit(Map.of());
+
+ assertThat(buffer.priorValueForBuffered("k"),
+ is(Maybe.defined(ValueTimestampHeaders.make("old",
RecordQueue.UNKNOWN, new RecordHeaders()))));
+
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>>
restored = bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(true);
+ restored.init(restoreContext, restored);
+ restoreInto(restoreContext, context, testName);
+
+ assertThat(restored.priorValueForBuffered("k"),
+ is(Maybe.defined(ValueTimestampHeaders.make("old",
RecordQueue.UNKNOWN, new RecordHeaders()))));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
+ @ParameterizedTest
+ @MethodSource("parameters")
+ public void
shouldRestoreChangelogWrittenWithHeadersIntoBufferWithoutHeaders(final String
testName, final Function<String, B> bufferSupplier) {
+ setup(testName, bufferSupplier);
+
+ // Offline downgrade: a changelog written by a run with
dsl.store.format=HEADERS must remain
+ // readable by a run without it (and, by the same token, by an older
version that knows
+ // nothing about the per-value-part record headers). This works
because the value bytes are
+ // plain V3 and the extra headers are simply ignored.
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>> buffer
= bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> context = makeContext(true);
+ buffer.init(context, buffer);
+
+ final RecordHeaders headers = new RecordHeaders(new Header[]{new
RecordHeader("h1", "v1".getBytes(UTF_8))});
+ context.setRecordContext(new ProcessorRecordContext(5L, 0, 0, "topic",
headers));
+ buffer.put(0L, new Record<>("k", new Change<>("new", "old"), 5L,
headers), context.recordContext());
+ buffer.commit(Map.of());
+
+ final List<ProducerRecord<Object, Object>> collected =
((MockRecordCollector) context.recordCollector()).collected();
+ assertThat(collected.size(), is(1));
+
+ // Restore into a buffer configured WITHOUT header stores.
+ final TimeOrderedKeyValueBuffer<String, String, Change<String>>
restored = bufferSupplier.apply(testName);
+ final MockInternalProcessorContext<?, ?> restoreContext =
makeContext(false);
+ restored.init(restoreContext, restored);
+ final RecordBatchingStateRestoreCallback stateRestoreCallback =
+ (RecordBatchingStateRestoreCallback)
restoreContext.stateRestoreCallback(testName);
+
+ final List<ConsumerRecord<byte[], byte[]>> toRestore = new
LinkedList<>();
+ for (final ProducerRecord<Object, Object> pr : collected) {
+ toRestore.add(new ConsumerRecord<>(
+ "changelog-topic", 0, 0, 999, TimestampType.CREATE_TIME, -1,
-1,
+ ((Bytes) pr.key()).get(), (byte[]) pr.value(), pr.headers(),
Optional.empty()));
+ }
+ stateRestoreCallback.restoreBatch(toRestore);
+
+ final List<Eviction<String, Change<String>>> evicted = new
LinkedList<>();
+ restored.evictWhile(() -> true, evicted::add);
+
+ // The values come back intact; only the per-part headers are absent,
which is exactly what
+ // running without a headers store format means.
+ assertThat(evicted.size(), is(1));
+ assertThat(evicted.get(0).key(), is("k"));
+ assertThat(evicted.get(0).value(), is(new Change<>("new", "old")));
+ assertThat(evicted.get(0).recordContext().timestamp(), is(5L));
+ cleanup(restoreContext, restored);
+ cleanup(context, buffer);
+ }
+
@ParameterizedTest
@MethodSource("parameters")
public void shouldCommit(final String testName, final Function<String, B>
bufferSupplier) {
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/UtilsTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/UtilsTest.java
index 1d08e898996..d1251936663 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/UtilsTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/UtilsTest.java
@@ -17,7 +17,9 @@
package org.apache.kafka.streams.state.internals;
import org.apache.kafka.common.errors.SerializationException;
+import org.apache.kafka.common.header.Header;
import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.common.serialization.StringSerializer;
@@ -37,8 +39,10 @@ import java.nio.charset.StandardCharsets;
import java.util.stream.Stream;
import static org.apache.kafka.streams.state.internals.Utils.hasEmptyHeaders;
+import static
org.apache.kafka.streams.state.internals.Utils.rawHeadersTimestampPrefix;
import static org.apache.kafka.streams.state.internals.Utils.rawPlainValue;
import static
org.apache.kafka.streams.state.internals.Utils.rawTimestampedValue;
+import static
org.apache.kafka.streams.state.internals.Utils.rawValueTimestampHeaders;
import static org.apache.kafka.streams.state.internals.Utils.readBytes;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -186,6 +190,76 @@ public class UtilsTest {
assertThrows(SerializationException.class, () -> readBytes(buf, 1));
}
+ @Test
+ public void shouldExtractHeadersTimestampPrefixWithHeaders() {
+ final byte[] headers = headersOf(HEADERS);
+ final byte[] input = headersTimestampValueOf(headers, VALUE);
+
+ // The prefix is everything rawPlainValue() strips:
[headersSize(varint)][headers][timestamp].
+ final byte[] expected = new byte[headers.length +
StateSerdes.TIMESTAMP_SIZE];
+ ByteBuffer.wrap(expected).put(headers).putLong(TIMESTAMP);
+
+ assertArrayEquals(expected, rawHeadersTimestampPrefix(input));
+ }
+
+ @Test
+ public void shouldExtractHeadersTimestampPrefixWithEmptyHeaders() {
+ // Empty headers encode as a single-byte varint, so the prefix is 1 +
8 bytes.
+ final byte[] prefix =
rawHeadersTimestampPrefix(timestampedValueWithEmptyHeaders(VALUE));
+
+ assertEquals(MIN_SIZE, prefix.length);
+ assertEquals((byte) 0x00, prefix[0]);
+ assertEquals(TIMESTAMP, ByteBuffer.wrap(prefix, 1,
StateSerdes.TIMESTAMP_SIZE).getLong());
+ }
+
+ @Test
+ public void shouldReturnNullForNullRawHeadersTimestampPrefix() {
+ assertNull(rawHeadersTimestampPrefix(null));
+ }
+
+ @Test
+ public void shouldRebuildValueTimestampHeadersFromPrefixAndPlainValue() {
+ final byte[] input = headersTimestampValueOf(headersOf(HEADERS),
VALUE);
+
+ // Splitting and rejoining must be lossless, for headers and for the
empty-headers case.
+ assertArrayEquals(input,
rawValueTimestampHeaders(rawHeadersTimestampPrefix(input),
rawPlainValue(input)));
+
+ final byte[] empty = timestampedValueWithEmptyHeaders(VALUE);
+ assertArrayEquals(empty,
rawValueTimestampHeaders(rawHeadersTimestampPrefix(empty),
rawPlainValue(empty)));
+ }
+
+ @Test
+ public void shouldReturnNullWhenRebuildingFromNullPlainValue() {
+
assertNull(rawValueTimestampHeaders(rawHeadersTimestampPrefix(timestampedValueWithEmptyHeaders(VALUE)),
null));
+ }
+
+ @Test
+ public void
shouldBuildValueTimestampHeadersFromPlainValueTimestampAndHeaders() {
+ final Headers headers = new RecordHeaders(new Header[]{new
RecordHeader("h", "hv".getBytes(StandardCharsets.UTF_8))});
+ final byte[] built = rawValueTimestampHeaders(VALUE, TIMESTAMP,
headers);
+
+ // Building must be the exact inverse of the extracting helpers.
+ assertArrayEquals(VALUE, rawPlainValue(built));
+ assertEquals(TIMESTAMP, Utils.timestamp(built));
+ assertEquals(headers, Utils.headers(built));
+ }
+
+ @Test
+ public void
shouldBuildValueTimestampHeadersWithEmptyHeadersLikeTheTimestampOnlyOverload() {
+ // Empty and null headers must both encode as headersSize=0, i.e.
exactly what the overload
+ // without headers produces -- otherwise the two writers would
disagree on the wire format.
+ final byte[] expected = timestampedValueWithEmptyHeaders(VALUE);
+
+ assertArrayEquals(expected, rawValueTimestampHeaders(VALUE,
TIMESTAMP));
+ assertArrayEquals(expected, rawValueTimestampHeaders(VALUE, TIMESTAMP,
new RecordHeaders()));
+ assertArrayEquals(expected, rawValueTimestampHeaders(VALUE, TIMESTAMP,
null));
+ }
+
+ @Test
+ public void shouldReturnNullWhenBuildingFromNullPlainValueWithHeaders() {
+ assertNull(rawValueTimestampHeaders(null, TIMESTAMP, new
RecordHeaders()));
+ }
+
private static byte[] timestampedValueWithEmptyHeaders(final byte[] value)
{
// header size: 1 byte, empty headers: 0 byte, timestamp: 8 bytes,
plain value length
final byte[] res = new byte[1 + 0 + StateSerdes.TIMESTAMP_SIZE +
value.length];