Eswarar Siva created KAFKA-21374:
------------------------------------

             Summary: 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.3.2, 4.4.0
            Reporter: Eswarar Siva


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)

Reply via email to