You are viewing a plain text version of this content. The canonical link for it is here.
Posted to jira@kafka.apache.org by "Matthias J. Sax (Jira)" <ji...@apache.org> on 2026/10/28 19:50:00 UTC
[jira] [Commented] (KAFKA-21374) Streams: reads of a global store pass the headers of the global thread's last record to the value deserializer
[ https://issues.apache.org/jira/browse/KAFKA-21374?page=com.atlassian.jira.plugin.system.issuetabpanels:comment-tabpanel&focusedCommentId=18133801#comment-18133801 ]
Matthias J. Sax commented on KAFKA-21374:
-----------------------------------------
{quote}Resetting the global processor context's record context after each update would also stop the headers from leaking past the update.
{quote}
At first glance, this seems to be the right fix? \cc [~alisa23]
> Streams: reads of a global store pass the headers of the global thread's last record to the value deserializer
> --------------------------------------------------------------------------------------------------------------
>
> Key: KAFKA-21374
> URL: https://issues.apache.org/jira/browse/KAFKA-21374
> Project: Kafka
> Issue Type: Bug
> Components: streams
> Affects Versions: 4.4.0, 4.3.2
> Reporter: Eswarar Siva
> Priority: Major
>
> Since KAFKA-20173 the metered stores pass internalContext.headers() to the serdes. For a global store that context is the global processor context, so every read of a global store gives the value deserializer the headers of the last record the global thread processed:
> -> a processor in a stream task that reads a global store gets the global thread's last record headers, not the headers of the record it is processing. A task store read in the same processor gets the right ones.
> -> an interactive query on a global store gets them too, while the comment in MeteredKeyValueStore (511-513, 534-536) says the IQ path gets new RecordHeaders() since there is no record context.
> 4.2.1 passed no headers on these reads. Line numbers below are at 4.3.2, the same code is in 4.4.0 and on trunk (not run on trunk).
> Why:
> -> GlobalStateManagerImpl.initialize inits every global store with globalProcessorContext (189).
> -> ProcessorContextImpl.getStateStore gives a stream task the same store instance, read only (167-170).
> -> MeteredKeyValueStore keeps that context from init (135) and passes internalContext.headers() to the serdes on get and put (449-461).
> -> GlobalStateUpdateTask.update sets the record context for each record (115-122) and nothing resets it.
> So what a stream thread's read gets depends on how far the global thread has got. That record context is written by the global thread and read by the stream threads without any synchronization.
> It matters for value serdes that look at headers. Confluent's Avro deserializer for example, by default (DualSchemaIdDeserializer in 8.2.2 and 8.3.0), takes the schema id from a __value_schema_id header when there is one and then reads the whole value as the payload. A global store value stored with the id in its prefix is then decoded with another record's schema id whenever the global thread's last record carried that header.
> Repro: TopologyTestDriver, in-memory stores, a value deserializer that records the "origin" header it is given. A global record with origin=global-record is piped first, then a stream record with origin=stream-record whose processor reads a task store and the global store, then an interactive query on the global store.
> Results:
> ||Read||4.2.1||4.3.2||4.4.0||
> |task store read in the stream task processor|called without headers|origin=stream-record|origin=stream-record|
> |global store read in the same processor|called without headers|origin=global-record|origin=global-record|
> |interactive query on the global store|called without headers|origin=global-record|origin=global-record|
> TopologyTestDriver runs the global and the stream processing on one thread. I also saw it on 4.3.2 with KafkaStreams against a real broker in one of our integration tests, where the global thread is its own thread.
> {code:java}
> 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.StringDeserializer;
> import org.apache.kafka.common.serialization.StringSerializer;
> import org.apache.kafka.streams.StreamsConfig;
> import org.apache.kafka.streams.Topology;
> import org.apache.kafka.streams.TopologyTestDriver;
> import org.apache.kafka.streams.processor.api.Processor;
> import org.apache.kafka.streams.processor.api.ProcessorContext;
> import org.apache.kafka.streams.processor.api.Record;
> import org.apache.kafka.streams.state.KeyValueStore;
> import org.apache.kafka.streams.state.Stores;
> import org.apache.kafka.streams.test.TestRecord;
> import java.nio.charset.StandardCharsets;
> import java.util.ArrayList;
> import java.util.List;
> import java.util.Properties;
> public class GlobalStoreHeadersRepro {
> static final List<String> SEEN = new ArrayList<>();
> static final class RecordingDeserializer implements Deserializer<String> {
> @Override
> public String deserialize(String topic, byte[] data) {
> SEEN.add("called without headers");
> return data == null ? null : new String(data, StandardCharsets.UTF_8);
> }
> @Override
> public String deserialize(String topic, Headers headers, byte[] data) {
> var origin = headers == null ? null : headers.lastHeader("origin");
> SEEN.add(origin == null ? "empty headers" : "origin=" + new String(origin.value(), StandardCharsets.UTF_8));
> return data == null ? null : new String(data, StandardCharsets.UTF_8);
> }
> }
> static Serde<String> recordingSerde() {
> return Serdes.serdeFrom(new StringSerializer(), new RecordingDeserializer());
> }
> static RecordHeaders origin(String value) {
> var headers = new RecordHeaders();
> headers.add("origin", value.getBytes(StandardCharsets.UTF_8));
> return headers;
> }
> public static void main(String[] args) {
> var topology = new Topology();
> topology.addGlobalStore(
> Stores.keyValueStoreBuilder(Stores.inMemoryKeyValueStore("global-store"), Serdes.String(),
> recordingSerde()).withLoggingDisabled(),
> "global-source", new StringDeserializer(), new StringDeserializer(), "global-topic",
> "global-processor", () -> new Processor<String, String, Void, Void>() {
> private KeyValueStore<String, String> store;
> @Override
> public void init(ProcessorContext<Void, Void> context) {
> store = context.getStateStore("global-store");
> }
> @Override
> public void process(Record<String, String> record) {
> store.put(record.key(), record.value());
> }
> });
> topology.addSource("stream-source", new StringDeserializer(), new StringDeserializer(), "stream-topic");
> topology.addProcessor("reader", () -> new Processor<String, String, Void, Void>() {
> private KeyValueStore<String, String> globalStore;
> private KeyValueStore<String, String> taskStore;
> @Override
> public void init(ProcessorContext<Void, Void> context) {
> globalStore = context.getStateStore("global-store");
> taskStore = context.getStateStore("task-store");
> }
> @Override
> public void process(Record<String, String> record) {
> taskStore.put("k", "task-value");
> SEEN.clear();
> taskStore.get("k");
> System.out.println("stream thread, task store read: " + SEEN);
> SEEN.clear();
> globalStore.get("k");
> System.out.println("stream thread, global store read: " + SEEN);
> }
> }, "stream-source");
> topology.addStateStore(
> Stores.keyValueStoreBuilder(Stores.inMemoryKeyValueStore("task-store"), Serdes.String(),
> recordingSerde()).withLoggingDisabled(),
> "reader");
> var props = new Properties();
> props.put(StreamsConfig.APPLICATION_ID_CONFIG, "global-store-headers-repro");
> props.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, "dummy:1234");
> try (var driver = new TopologyTestDriver(topology, props)) {
> var globalInput = driver.createInputTopic("global-topic", new StringSerializer(), new StringSerializer());
> var streamInput = driver.createInputTopic("stream-topic", new StringSerializer(), new StringSerializer());
> globalInput.pipeInput(new TestRecord<>("k", "global-value", origin("global-record")));
> System.out.println("piped the stream record with origin=stream-record");
> streamInput.pipeInput(new TestRecord<>("x", "y", origin("stream-record")));
> SEEN.clear();
> KeyValueStore<String, String> queried = driver.getKeyValueStore("global-store");
> queried.get("k");
> System.out.println("interactive query, global store read: " + SEEN);
> }
> }
> }
> {code}
> What I think could be done: give reads from outside the global thread (stream task processors and interactive queries) either empty headers or the headers of the reading task's current record. Not sure which one fits best. Resetting the global processor context's record context after each update would also stop the headers from leaking past the update.
--
This message was sent by Atlassian Jira
(v10.3.26#10030026)