Skip to content

Commit 3408aa3

Browse files
authored
KAFKA-20538: Make RocksDBTimeOrderedKeyValueBuffer headers-aware (#22166)
Problem: `RocksDBTimeOrderedKeyValueBuffer` (the persistent buffer used by stream-table joins with grace periods) was missed when headers were propagated through Streams serdes. Two bugs: 1. evictWhile() deserializes with the wrong headers. Both key and value deserializers are called with internalContext.headers() -- the headers of whatever record the processor is currently handling, not the record being evicted. Since records can sit in the buffer for the entire grace period, these almost never match. 2. put() does not persist the record's headers. It stores the caller-supplied recordContext directly in BufferValue, so record.headers() is never copied into the persisted `ProcessorRecordContext` and is unrecoverable at eviction time. This PR fixes the followings: - put() builds a fresh ProcessorRecordContext carrying record.headers() and stores that in BufferValue, so the headers are persisted with the record - evictWhile() uses bufferValue.context().headers() for both key and value deserialization, so each evicted record is deserialized with the headers it was buffered with. Reviewers: Matthias J. Sax <matthias@confluent.io>
1 parent 2345482 commit 3408aa3

3 files changed

Lines changed: 194 additions & 2 deletions

File tree

streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimeOrderedKeyValueBuffer.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ public void evictWhile(final Supplier<Boolean> predicate, final Consumer<Evictio
231231

232232
final BufferValue bufferValue = BufferValue.deserialize(ByteBuffer.wrap(keyValue.value));
233233
final K key = keySerde.deserializer().deserialize(topic,
234-
internalContext.headers(),
234+
bufferValue.context().headers(),
235235
PrefixedWindowKeySchemas.TimeFirstWindowKeySchema.extractStoreKeyBytes(keyValue.key.get()));
236236

237237
if (bufferValue.context().timestamp() < minTimestamp && minValid) {
@@ -243,7 +243,7 @@ public void evictWhile(final Supplier<Boolean> predicate, final Consumer<Evictio
243243
minTimestamp = bufferValue.context().timestamp();
244244
minValid = true;
245245

246-
final V value = valueSerde.deserializer().deserialize(topic, internalContext.headers(), bufferValue.newValue());
246+
final V value = valueSerde.deserializer().deserialize(topic, bufferValue.context().headers(), bufferValue.newValue());
247247

248248
callback.accept(new Eviction<>(key, value, bufferValue.context()));
249249

streams/src/test/java/org/apache/kafka/streams/kstream/internals/KStreamKTableJoinTest.java

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,15 @@
1717
package org.apache.kafka.streams.kstream.internals;
1818

1919
import org.apache.kafka.common.MetricName;
20+
import org.apache.kafka.common.header.Header;
21+
import org.apache.kafka.common.header.Headers;
22+
import org.apache.kafka.common.header.internals.RecordHeader;
23+
import org.apache.kafka.common.header.internals.RecordHeaders;
24+
import org.apache.kafka.common.serialization.Deserializer;
2025
import org.apache.kafka.common.serialization.IntegerSerializer;
26+
import org.apache.kafka.common.serialization.Serde;
2127
import org.apache.kafka.common.serialization.Serdes;
28+
import org.apache.kafka.common.serialization.Serializer;
2229
import org.apache.kafka.common.serialization.StringSerializer;
2330
import org.apache.kafka.common.utils.LogCaptureAppender;
2431
import org.apache.kafka.streams.KeyValue;
@@ -35,6 +42,7 @@
3542
import org.apache.kafka.streams.kstream.KTable;
3643
import org.apache.kafka.streams.kstream.Materialized;
3744
import org.apache.kafka.streams.state.Stores;
45+
import org.apache.kafka.streams.test.TestRecord;
3846
import org.apache.kafka.test.MockApiProcessor;
3947
import org.apache.kafka.test.MockApiProcessorSupplier;
4048
import org.apache.kafka.test.MockValueJoiner;
@@ -44,6 +52,7 @@
4452
import org.junit.jupiter.params.ParameterizedTest;
4553
import org.junit.jupiter.params.provider.ValueSource;
4654

55+
import java.nio.charset.StandardCharsets;
4756
import java.time.Duration;
4857
import java.time.Instant;
4958
import java.util.Collection;
@@ -311,6 +320,82 @@ public void shouldHandleLateJoinsWithGracePeriod(final boolean withHeaders) {
311320
new KeyValueTimestamp<>(0, "X0+Y0", 0));
312321
}
313322

323+
@ParameterizedTest
324+
@ValueSource(booleans = {false, true})
325+
public void shouldDeserializeBufferedValueWithPutTimeHeadersDuringEviction(final boolean withHeaders) {
326+
builder = new StreamsBuilder();
327+
final Consumed<Integer, String> consumed = Consumed.with(Serdes.Integer(), Serdes.String());
328+
final KStream<Integer, String> stream = builder.stream(streamTopic, consumed);
329+
final KTable<Integer, String> table = builder.table("tableTopic2", consumed, Materialized.as(
330+
Stores.persistentVersionedKeyValueStore("V-grace", Duration.ofMinutes(5))));
331+
stream.join(table,
332+
MockValueJoiner.TOSTRING_JOINER,
333+
// The built-in leaf serdes (String, primitives, …) ignore the `headers` argument, so with any of them the test would pass identically
334+
// with and without the fix (a false green). The HeaderValueAppendingSerde is the minimal serde whose deserializer reflects the `headers`
335+
// argument into the value
336+
Joined.with(Serdes.Integer(), new HeaderValueAppendingSerde(), Serdes.String(), "Grace", Duration.ofMillis(2))
337+
).process(supplier);
338+
final Properties props = StreamsTestUtils.getStreamsConfig(Serdes.Integer(), Serdes.String());
339+
StreamsTestUtils.maybeSetDslStoreFormatHeaders(props, withHeaders);
340+
driver = new TopologyTestDriver(builder.build(), props);
341+
inputStreamTopic = driver.createInputTopic(streamTopic, new IntegerSerializer(), new StringSerializer(), Instant.ofEpochMilli(0L), Duration.ZERO);
342+
inputTableTopic = driver.createInputTopic("tableTopic2", new IntegerSerializer(), new StringSerializer(), Instant.ofEpochMilli(0L), Duration.ZERO);
343+
processor = supplier.theCapturedProcessor();
344+
345+
// Table entry so the buffered stream record finds a join match when it is evicted.
346+
inputTableTopic.pipeInput(0, "Y0", 0L);
347+
348+
// Record A is buffered (grace not yet elapsed) with header v=first at t=0. Nothing is emitted yet.
349+
inputStreamTopic.pipeInput(new TestRecord<>(0, "X0", headers("first"), 0L));
350+
processor.checkAndClearProcessResult(EMPTY);
351+
352+
// Record B carries header v=second and its t=10 timestamp advances stream time past A's grace window, which
353+
// triggers A's eviction while the live processor context still holds B's headers (v=second). B itself is
354+
// buffered, not evicted, so it produces no output here.
355+
inputStreamTopic.pipeInput(new TestRecord<>(1, "X1", headers("second"), 10L));
356+
357+
// A must be deserialized with its own put-time header (first), NOT B's live-context header (second). Before
358+
// the fix this asserted "X0[second]+Y0".
359+
processor.checkAndClearProcessResult(new KeyValueTimestamp<>(0, "X0[first]+Y0", 0));
360+
}
361+
362+
private static Headers headers(final String value) {
363+
return new RecordHeaders(new Header[]{new RecordHeader("v", value.getBytes(StandardCharsets.UTF_8))});
364+
}
365+
366+
/**
367+
* A stream-side value {@link Serde} whose <em>deserializer</em> output depends on the headers it is handed: it
368+
* appends the value of the {@code "v"} header to the deserialized string. Serialization is a plain string encode
369+
* and ignores headers. This fixture exists so tests can observe <em>which</em> headers reached the buffer's value
370+
* deserializer during eviction -- a plain serde would ignore the headers argument and hide the difference.
371+
*/
372+
private static final class HeaderValueAppendingSerde implements Serde<String> {
373+
@Override
374+
public Serializer<String> serializer() {
375+
return new StringSerializer();
376+
}
377+
378+
@Override
379+
public Deserializer<String> deserializer() {
380+
return new Deserializer<>() {
381+
@Override
382+
public String deserialize(final String topic, final byte[] data) {
383+
return data == null ? null : new String(data, StandardCharsets.UTF_8);
384+
}
385+
386+
@Override
387+
public String deserialize(final String topic, final Headers headers, final byte[] data) {
388+
final String base = deserialize(topic, data);
389+
final Header header = headers == null ? null : headers.lastHeader("v");
390+
if (base == null || header == null) {
391+
return base;
392+
}
393+
return base + "[" + new String(header.value(), StandardCharsets.UTF_8) + "]";
394+
}
395+
};
396+
}
397+
}
398+
314399
@ParameterizedTest
315400
@ValueSource(booleans = {false, true})
316401
public void shouldReuseRepartitionTopicWithGeneratedName(final boolean withHeaders) {

streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimeOrderedKeyValueBufferTest.java

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@
1616
*/
1717
package org.apache.kafka.streams.state.internals;
1818

19+
import org.apache.kafka.common.header.Header;
20+
import org.apache.kafka.common.header.Headers;
21+
import org.apache.kafka.common.header.internals.RecordHeader;
1922
import org.apache.kafka.common.header.internals.RecordHeaders;
2023
import org.apache.kafka.common.metrics.Metrics;
2124
import org.apache.kafka.common.metrics.Sensor;
25+
import org.apache.kafka.common.serialization.Deserializer;
2226
import org.apache.kafka.common.serialization.Serde;
2327
import org.apache.kafka.common.serialization.Serdes;
28+
import org.apache.kafka.common.serialization.Serializer;
29+
import org.apache.kafka.common.serialization.StringDeserializer;
30+
import org.apache.kafka.common.serialization.StringSerializer;
2431
import org.apache.kafka.common.utils.MockTime;
2532
import org.apache.kafka.streams.processor.TaskId;
2633
import org.apache.kafka.streams.processor.api.Record;
@@ -40,11 +47,17 @@
4047
import org.mockito.junit.jupiter.MockitoSettings;
4148
import org.mockito.quality.Strictness;
4249

50+
import java.nio.charset.StandardCharsets;
4351
import java.time.Duration;
52+
import java.util.ArrayList;
53+
import java.util.List;
4454
import java.util.concurrent.atomic.AtomicInteger;
4555

4656
import static org.hamcrest.MatcherAssert.assertThat;
4757
import static org.hamcrest.Matchers.equalTo;
58+
import static org.hamcrest.Matchers.everyItem;
59+
import static org.hamcrest.Matchers.hasItem;
60+
import static org.hamcrest.Matchers.is;
4861
import static org.mockito.Mockito.when;
4962

5063
@ExtendWith(MockitoExtension.class)
@@ -205,6 +218,100 @@ public void shouldHandleCollidingKeys() {
205218
assertNumSizeAndTimestamp(buffer, 1, 7, 42);
206219
}
207220

221+
@Test
222+
public void shouldDeserializeWithPutTimeHeadersEvenAfterContextMutation() {
223+
final HeaderCapturingSerde serde = new HeaderCapturingSerde();
224+
createBuffer(Duration.ZERO, serde);
225+
final RecordHeaders putHeaders = new RecordHeaders(new Header[]{
226+
new RecordHeader("at-put", "first".getBytes(StandardCharsets.UTF_8))
227+
});
228+
// Give the record its own headers, distinct from the context headers, so the assertions below prove that
229+
// eviction deserialization reads from the stored recordContext snapshot rather than from record.headers().
230+
final RecordHeaders recordHeaders = new RecordHeaders(new Header[]{
231+
new RecordHeader("on-record", "rec".getBytes(StandardCharsets.UTF_8))
232+
});
233+
context.setRecordContext(new ProcessorRecordContext(0L, offset++, 0, "testing", putHeaders));
234+
buffer.put(0L, new Record<>("k", "v", 0L, recordHeaders), context.recordContext());
235+
236+
// Simulate the processor moving on to another record with different headers before eviction runs.
237+
final RecordHeaders laterHeaders = new RecordHeaders(new Header[]{
238+
new RecordHeader("at-evict", "second".getBytes(StandardCharsets.UTF_8))
239+
});
240+
context.setRecordContext(new ProcessorRecordContext(0L, offset++, 0, "testing", laterHeaders));
241+
242+
final List<TimeOrderedKeyValueBuffer.Eviction<String, String>> evicted = new ArrayList<>();
243+
buffer.evictWhile(() -> buffer.numRecords() > 0, evicted::add);
244+
245+
assertThat(evicted.size(), is(1));
246+
// The key/value deserializers must see the headers captured at put time, not the mutated context headers.
247+
assertThat(serde.capturedHeaders, hasItem(putHeaders));
248+
assertThat(serde.capturedHeaders, everyItem(is(putHeaders)));
249+
assertThat(evicted.get(0).recordContext().headers(), is(putHeaders));
250+
}
251+
252+
@Test
253+
public void shouldNotBeAffectedByProcessorContextHeaderMutationBetweenPutAndEvict() {
254+
final HeaderCapturingSerde serde = new HeaderCapturingSerde();
255+
createBuffer(Duration.ofMillis(1), serde);
256+
final RecordHeaders putHeaders = new RecordHeaders(new Header[]{
257+
new RecordHeader("at-put", "first".getBytes(StandardCharsets.UTF_8))
258+
});
259+
// Give the record its own headers, distinct from the context headers, so the assertions below prove that
260+
// eviction deserialization reads from the stored recordContext snapshot rather than from record.headers().
261+
final RecordHeaders recordHeaders = new RecordHeaders(new Header[]{
262+
new RecordHeader("on-record", "rec".getBytes(StandardCharsets.UTF_8))
263+
});
264+
context.setRecordContext(new ProcessorRecordContext(0L, offset++, 0, "testing", putHeaders));
265+
buffer.put(0L, new Record<>("k", "v", 0L, recordHeaders), context.recordContext());
266+
267+
// Simulate the processor moving on to handle a different record with different headers
268+
// before the grace period expires and eviction runs.
269+
final RecordHeaders laterHeaders = new RecordHeaders(new Header[]{
270+
new RecordHeader("at-evict", "second".getBytes(StandardCharsets.UTF_8))
271+
});
272+
final RecordHeaders triggerRecordHeaders = new RecordHeaders(new Header[]{
273+
new RecordHeader("on-trigger-record", "trig".getBytes(StandardCharsets.UTF_8))
274+
});
275+
context.setRecordContext(new ProcessorRecordContext(10L, offset++, 0, "testing", laterHeaders));
276+
// Advance stream time past the grace period for the original record.
277+
buffer.put(10L, new Record<>("trigger", "v", 10L, triggerRecordHeaders), context.recordContext());
278+
279+
final List<TimeOrderedKeyValueBuffer.Eviction<String, String>> evicted = new ArrayList<>();
280+
buffer.evictWhile(() -> true, evicted::add);
281+
282+
// Only the original "k" record at t=0 falls outside the grace window of t=10.
283+
assertThat(evicted.size(), is(1));
284+
assertThat(evicted.get(0).key(), is("k"));
285+
// The deserializers for the evicted record must see its put-time headers, not the later context headers.
286+
assertThat(serde.capturedHeaders, hasItem(putHeaders));
287+
assertThat(serde.capturedHeaders, everyItem(is(putHeaders)));
288+
assertThat(evicted.get(0).recordContext().headers(), is(putHeaders));
289+
}
290+
291+
/**
292+
* A {@link Serde} whose deserializer records the {@link Headers} it is handed on each call, so tests can assert
293+
* which headers reached the key/value deserializers during eviction. Serialization behaves like a plain String serde.
294+
*/
295+
private static final class HeaderCapturingSerde implements Serde<String> {
296+
private final List<Headers> capturedHeaders = new ArrayList<>();
297+
298+
@Override
299+
public Serializer<String> serializer() {
300+
return new StringSerializer();
301+
}
302+
303+
@Override
304+
public Deserializer<String> deserializer() {
305+
return new StringDeserializer() {
306+
@Override
307+
public String deserialize(final String topic, final Headers headers, final byte[] data) {
308+
capturedHeaders.add(headers);
309+
return super.deserialize(topic, data);
310+
}
311+
};
312+
}
313+
}
314+
208315
private void assertNumSizeAndTimestamp(final TimeOrderedKeyValueBuffer<String, String, String> buffer,
209316
final int num,
210317
final long time,

0 commit comments

Comments
 (0)