This is an automated email from the ASF dual-hosted git repository.

frankvicky 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 eb7096dcf65 KAFKA-20703: IQv2 TimestampedKeyWithHeadersQuery for 
headers-aware key-value stores (KIP-1356) (#22666)
eb7096dcf65 is described below

commit eb7096dcf65b422bf346ed46fb8a9ebfc2612e72
Author: Jess668 <[email protected]>
AuthorDate: Wed Jul 8 04:08:00 2026 -0400

    KAFKA-20703: IQv2 TimestampedKeyWithHeadersQuery for headers-aware 
key-value stores (KIP-1356) (#22666)
    
    This PR is the **first increment of KIP-1356** — the key/value point
    query — for  `TimestampedKeyValueStoreWithHeaders`. The
    range/window/session header query types described in the  KIP will
    follow in subsequent PRs.
    
    ## Summary
    
    1. **`TimestampedKeyWithHeadersQuery<K, V>`** (new, `@Evolving`,
    `org.apache.kafka.streams.query`) —
       the headers-aware parallel of `TimestampedKeyQuery`, returning a
    `ReadOnlyRecord` that carries the
       key, value, timestamp, and headers.
    2. **Native header store serves basic IQv2 queries.**
    `RocksDBTimestampedStoreWithHeaders` no longer
       overrides `query(...)` to return `UNKNOWN_QUERY_TYPE`; it inherits
    `RocksDBStore.query()`, so the
       existing query types (`KeyQuery`, `RangeQuery`, …) behave identically
    on both build paths.
    3. **Header store caching participates in IQv2 point queries.**
    `CachingKeyValueStoreWithHeaders` no
       longer overrides `query(...)` to bypass the cache; it inherits
    `CachingKeyValueStore.query()`, so
       point queries consult the record cache (read-your-writes) and honor
    `skipCache`.
    4. **`skipCache` is honored for the new query.**
    `MeteredTimestampedKeyValueStoreWithHeaders` forwards `isSkipCache()`
    from `TimestampedKeyWithHeadersQuery` onto the raw `KeyQuery` it builds,
    so the caching layer can honor it. (Making `skipCache()` work for the
    existing `KeyQuery`/`TimestampedKeyQuery` on the metered stores is a
    pre-existing, cross-cutting no-op fix — out of scope here, to be
    addressed in a follow-up
    [JIRA](https://issues.apache.org/jira/browse/KAFKA-20776).)
    
    The metered header store services `TimestampedKeyWithHeadersQuery` by
    forwarding a raw byte-level  `KeyQuery` to the wrapped store and
    deserializing the stored `ValueTimestampHeaders` into a `Record`  (a
    `ReadOnlyRecord`); an absent or tombstoned key yields a `null` result.
    
    ## Testing
    
    - `MeteredTimestampedKeyValueStoreWithHeadersTest` — `skipCache` is
    propagated to the forwarded
      `KeyQuery` for `TimestampedKeyWithHeadersQuery`.
    - `RocksDBTimestampedStoreWithHeadersTest` — the native store serves
    `KeyQuery` (previously `UNKNOWN_QUERY_TYPE`); a genuinely unsupported
    query and `RangeQuery` still returns `UNKNOWN_QUERY_TYPE`.
    - `TimestampedKeyValueStoreBuilderWithHeadersTest` — IQv2 query handling
    parameterized over build path
      (native / adapter / in-memory) × caching on/off, plus a
    native-vs-adapter result-parity test.
    - `IQv2HeadersStoreIntegrationTest` — end-to-end value/timestamp/headers
    round-trip,
      empty-headers, tombstone/absent, `UNKNOWN_QUERY_TYPE` against a
    non-headers store, and a
      caching-enabled cache-hit (read-your-writes) case.
    
    Reviewers: Alieh Saeedi <[email protected]>, TengYao Chi
     <[email protected]>
---
 .../IQv2HeadersStoreIntegrationTest.java           | 347 +++++++++
 .../query/TimestampedKeyWithHeadersQuery.java      | 100 +++
 .../internals/CachingKeyValueStoreWithHeaders.java |  20 +-
 ...MeteredTimestampedKeyValueStoreWithHeaders.java |  78 +-
 .../RocksDBTimestampedStoreWithHeaders.java        |  11 +-
 ...redTimestampedKeyValueStoreWithHeadersTest.java |  34 +
 .../RocksDBTimestampedStoreWithHeadersTest.java    |  53 +-
 ...stampedKeyValueStoreBuilderWithHeadersTest.java | 808 ++++++++-------------
 8 files changed, 918 insertions(+), 533 deletions(-)

diff --git 
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
new file mode 100644
index 00000000000..f8eb62da2e1
--- /dev/null
+++ 
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
@@ -0,0 +1,347 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.integration;
+
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.clients.producer.RecordMetadata;
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.serialization.IntegerSerializer;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.apache.kafka.streams.KafkaStreams;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsBuilder;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
+import org.apache.kafka.streams.integration.utils.IntegrationTestUtils;
+import org.apache.kafka.streams.kstream.Consumed;
+import org.apache.kafka.streams.kstream.Produced;
+import org.apache.kafka.streams.processor.api.Processor;
+import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
+import org.apache.kafka.streams.processor.api.Record;
+import org.apache.kafka.streams.query.FailureReason;
+import org.apache.kafka.streams.query.Position;
+import org.apache.kafka.streams.query.PositionBound;
+import org.apache.kafka.streams.query.QueryResult;
+import org.apache.kafka.streams.query.StateQueryRequest;
+import org.apache.kafka.streams.query.StateQueryResult;
+import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
+import org.apache.kafka.streams.state.StoreBuilder;
+import org.apache.kafka.streams.state.Stores;
+import org.apache.kafka.streams.state.TimestampedKeyValueStore;
+import org.apache.kafka.streams.state.TimestampedKeyValueStoreWithHeaders;
+import org.apache.kafka.streams.state.ValueAndTimestamp;
+import org.apache.kafka.streams.state.ValueTimestampHeaders;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInfo;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import static org.apache.kafka.streams.query.StateQueryRequest.inStore;
+import static org.apache.kafka.streams.utils.TestUtils.safeUniqueTestName;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * IQv2 integration tests for KIP-1271/KIP-1356 headers-aware state stores.
+ *
+ * <p>It builds a KIP-1271 {@code WithHeaders} store, writes records (with 
headers) into it through a processor,
+ * and queries it through IQv2. Currently covers {@link 
TimestampedKeyWithHeadersQuery}; the remaining KIP-1356 headers
+ * query types (range/window/session) are expected to extend this class as 
they land.
+ */
+@Tag("integration")
+public class IQv2HeadersStoreIntegrationTest {
+
+    private static final String STORE_NAME = "headers-store";
+
+    private static final EmbeddedKafkaCluster CLUSTER = new 
EmbeddedKafkaCluster(1);
+
+    private static final Headers HEADERS = new RecordHeaders()
+        .add("source", "test".getBytes())
+        .add("version", "1.0".getBytes());
+
+    private String inputStream;
+    private String outputStream;
+    private long baseTimestamp;
+    private long commitIntervalMs = 1000L;
+    private Position inputPosition;
+    private KafkaStreams kafkaStreams;
+    private TestInfo testInfo;
+
+    @BeforeAll
+    public static void before() throws IOException {
+        CLUSTER.start();
+    }
+
+    @AfterAll
+    public static void after() {
+        CLUSTER.stop();
+    }
+
+    @BeforeEach
+    public void beforeTest(final TestInfo testInfo) throws 
InterruptedException {
+        this.testInfo = testInfo;
+        final String uniqueTestName = safeUniqueTestName(testInfo);
+        inputStream = "input-stream-" + uniqueTestName;
+        outputStream = "output-stream-" + uniqueTestName;
+        CLUSTER.createTopic(inputStream);
+        CLUSTER.createTopic(outputStream);
+        baseTimestamp = CLUSTER.time.milliseconds();
+        inputPosition = Position.emptyPosition();
+    }
+
+    @AfterEach
+    public void afterTest() {
+        if (kafkaStreams != null) {
+            kafkaStreams.close(Duration.ofSeconds(30L));
+            kafkaStreams.cleanUp();
+        }
+    }
+
+    @Test
+    public void shouldHandleTimestampedKeyWithHeadersQuery() throws Exception {
+        startStreams();
+
+        // key 1 has headers, key 2 has empty headers, key 3 is tombstoned 
(null value)
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
+            KeyValue.pair(1, "a0"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 1, new 
RecordHeaders(),
+            KeyValue.pair(2, "b0"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 2, HEADERS,
+            KeyValue.pair(3, "c0"), KeyValue.pair(3, null));
+
+        // key 1: key + value + timestamp + headers round-trip
+        final ReadOnlyRecord<Integer, String> result1 = query(1);
+        assertEquals(Integer.valueOf(1), result1.key());
+        assertEquals("a0", result1.value());
+        assertEquals(baseTimestamp, result1.timestamp());
+        assertEquals(HEADERS, result1.headers());
+
+        // key 2: written with no headers -> empty (never null) headers
+        final ReadOnlyRecord<Integer, String> result2 = query(2);
+        assertEquals(Integer.valueOf(2), result2.key());
+        assertEquals("b0", result2.value());
+        assertEquals(baseTimestamp + 1, result2.timestamp());
+        assertEquals(new RecordHeaders(), result2.headers());
+
+        // key 3: tombstoned -> null result, never a partially-populated 
wrapper
+        assertNull(query(3));
+
+        // never-written key -> null result
+        assertNull(query(999));
+    }
+
+    @Test
+    public void shouldServeCacheHitWhenCachingEnabledAndRecordNotYetFlushed() 
throws Exception {
+        // Use a very large commit interval so the processed record is never 
committed/flushed during
+        // the test: it lives only in the record cache (the persistent RocksDB 
layer stays empty). A
+        // successful query therefore proves the result was served from the 
cache via
+        // CachingKeyValueStoreWithHeaders -> the metered store's cache-hit 
path, end-to-end.
+        commitIntervalMs = Duration.ofMinutes(10).toMillis();
+        startStreams(true);
+
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS, 
KeyValue.pair(1, "a0"));
+
+        // Read-your-writes: the not-yet-flushed record is visible (served 
from the cache), with headers.
+        final ReadOnlyRecord<Integer, String> result = query(1);
+        // skipCache bypasses the cache and reads the persistent store 
directly. A null result positively
+        // proves nothing has been flushed, so the read above was genuinely 
cache-served (not an accidental
+        // store read) -- and it covers skipCache end-to-end.
+        assertNull(querySkipCache(1));
+        assertEquals(Integer.valueOf(1), result.key());
+        assertEquals("a0", result.value());
+        assertEquals(baseTimestamp, result.timestamp());
+        assertEquals(HEADERS, result.headers());
+    }
+
+    @Test
+    public void shouldFailWithUnknownQueryTypeAgainstNonHeadersStore() throws 
Exception {
+        // store built WITHOUT a WithHeaders supplier -> the query type is 
unsupported
+        final StreamsBuilder builder = new StreamsBuilder();
+        builder
+            .addStateStore(
+                Stores.timestampedKeyValueStoreBuilder(
+                    Stores.persistentTimestampedKeyValueStore(STORE_NAME),
+                    Serdes.Integer(),
+                    Serdes.String()))
+            .stream(inputStream, Consumed.with(Serdes.Integer(), 
Serdes.String()))
+            .process(() -> new PlainStoreWriterProcessor(), STORE_NAME)
+            .to(outputStream, Produced.with(Serdes.Integer(), 
Serdes.String()));
+
+        kafkaStreams = new KafkaStreams(builder.build(), props());
+        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS, 
KeyValue.pair(1, "a0"));
+
+        final StateQueryRequest<ReadOnlyRecord<Integer, String>> request =
+            inStore(STORE_NAME)
+                .withQuery(TimestampedKeyWithHeadersQuery.<Integer, 
String>withKey(1))
+                .withPositionBound(PositionBound.at(inputPosition));
+        final StateQueryResult<ReadOnlyRecord<Integer, String>> result =
+            IntegrationTestUtils.iqv2WaitForResult(kafkaStreams, request);
+
+        assertTrue(result.getOnlyPartitionResult().isFailure());
+        assertEquals(FailureReason.UNKNOWN_QUERY_TYPE, 
result.getOnlyPartitionResult().getFailureReason());
+    }
+
+    private void startStreams() throws Exception {
+        // Caching disabled: every IQv2 query is forced down to the persistent
+        // RocksDBTimestampedStoreWithHeaders layer, exercising its KeyQuery 
handling
+        // (rather than being short-circuited by a cache hit).
+        startStreams(false);
+    }
+
+    private void startStreams(final boolean cachingEnabled) throws Exception {
+        final StreamsBuilder builder = new StreamsBuilder();
+        final StoreBuilder<TimestampedKeyValueStoreWithHeaders<Integer, 
String>> storeBuilder =
+            Stores.timestampedKeyValueStoreWithHeadersBuilder(
+                
Stores.persistentTimestampedKeyValueStoreWithHeaders(STORE_NAME),
+                Serdes.Integer(),
+                Serdes.String());
+        builder
+            .addStateStore(cachingEnabled ? storeBuilder.withCachingEnabled() 
: storeBuilder.withCachingDisabled())
+            .stream(inputStream, Consumed.with(Serdes.Integer(), 
Serdes.String()))
+            .process(() -> new HeadersStoreWriterProcessor(), STORE_NAME)
+            .to(outputStream, Produced.with(Serdes.Integer(), 
Serdes.String()));
+
+        kafkaStreams = new KafkaStreams(builder.build(), props());
+        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+    }
+
+    private ReadOnlyRecord<Integer, String> query(final int key) {
+        final StateQueryRequest<ReadOnlyRecord<Integer, String>> request =
+            inStore(STORE_NAME)
+                .withQuery(TimestampedKeyWithHeadersQuery.<Integer, 
String>withKey(key))
+                .withPositionBound(PositionBound.at(inputPosition));
+        // Retry until the store has caught up to the produced input position; 
freshness comes from the
+        // IQv2 position mechanism rather than from output-topic consumption.
+        final StateQueryResult<ReadOnlyRecord<Integer, String>> result =
+            IntegrationTestUtils.iqv2WaitForResult(kafkaStreams, request);
+        // getOnlyPartitionResult() returns null when the single partition 
result is a successful
+        // null (tombstoned / absent key), which we surface to the caller as a 
null lookup.
+        final QueryResult<ReadOnlyRecord<Integer, String>> onlyResult = 
result.getOnlyPartitionResult();
+        return onlyResult == null ? null : onlyResult.getResult();
+    }
+
+    private ReadOnlyRecord<Integer, String> querySkipCache(final int key) {
+        // skipCache forwards the query past the record cache to the 
persistent store. Use the default
+        // (unbounded) position bound on purpose: the persistent layer may 
legitimately be empty (nothing
+        // flushed), so bounding on the input position would never be 
satisfied.
+        final StateQueryRequest<ReadOnlyRecord<Integer, String>> request =
+            inStore(STORE_NAME).withQuery(
+                TimestampedKeyWithHeadersQuery.<Integer, 
String>withKey(key).skipCache());
+        final StateQueryResult<ReadOnlyRecord<Integer, String>> result = 
kafkaStreams.query(request);
+        final QueryResult<ReadOnlyRecord<Integer, String>> onlyResult = 
result.getOnlyPartitionResult();
+        return onlyResult == null ? null : onlyResult.getResult();
+    }
+
+    private Properties props() {
+        final String safeTestName = safeUniqueTestName(testInfo);
+        final Properties streamsConfiguration = new Properties();
+        streamsConfiguration.put(StreamsConfig.APPLICATION_ID_CONFIG, "app-" + 
safeTestName);
+        streamsConfiguration.put(StreamsConfig.BOOTSTRAP_SERVERS_CONFIG, 
CLUSTER.bootstrapServers());
+        streamsConfiguration.put(StreamsConfig.STATE_DIR_CONFIG, 
TestUtils.tempDirectory().getPath());
+        streamsConfiguration.put(StreamsConfig.COMMIT_INTERVAL_MS_CONFIG, 
commitIntervalMs);
+        streamsConfiguration.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, 
"earliest");
+        return streamsConfiguration;
+    }
+
+    @SuppressWarnings("varargs")
+    @SafeVarargs
+    private final void produceDataToTopicWithHeaders(final String topic,
+                                                     final long timestamp,
+                                                     final Headers headers,
+                                                     final KeyValue<Integer, 
String>... keyValues) {
+        final Properties producerConfig =
+            TestUtils.producerConfig(CLUSTER.bootstrapServers(), 
IntegerSerializer.class, StringSerializer.class);
+        final List<Future<RecordMetadata>> futures = new LinkedList<>();
+        try (final Producer<Integer, String> producer = new 
KafkaProducer<>(producerConfig)) {
+            for (final KeyValue<Integer, String> keyValue : keyValues) {
+                futures.add(producer.send(
+                    new ProducerRecord<>(topic, null, timestamp, keyValue.key, 
keyValue.value, headers)));
+            }
+            producer.flush();
+            for (final Future<RecordMetadata> future : futures) {
+                try {
+                    final RecordMetadata metadata = future.get(60, 
TimeUnit.SECONDS);
+                    // Track the produced input Position so queries can bound 
on it (IQv2 freshness).
+                    inputPosition = inputPosition.withComponent(
+                        metadata.topic(), metadata.partition(), 
metadata.offset());
+                } catch (final Exception e) {
+                    throw new RuntimeException("Failed to produce test record 
to " + topic, e);
+                }
+            }
+        }
+    }
+
+    private static class HeadersStoreWriterProcessor implements 
Processor<Integer, String, Integer, String> {
+        private ProcessorContext<Integer, String> context;
+        private TimestampedKeyValueStoreWithHeaders<Integer, String> store;
+
+        @Override
+        public void init(final ProcessorContext<Integer, String> context) {
+            this.context = context;
+            store = context.getStateStore(STORE_NAME);
+        }
+
+        @Override
+        public void process(final Record<Integer, String> record) {
+            store.put(
+                record.key(),
+                ValueTimestampHeaders.make(record.value(), record.timestamp(), 
record.headers()));
+            context.forward(record);
+        }
+    }
+
+    private static class PlainStoreWriterProcessor implements 
Processor<Integer, String, Integer, String> {
+        private ProcessorContext<Integer, String> context;
+        private TimestampedKeyValueStore<Integer, String> store;
+
+        @Override
+        public void init(final ProcessorContext<Integer, String> context) {
+            this.context = context;
+            store = context.getStateStore(STORE_NAME);
+        }
+
+        @Override
+        public void process(final Record<Integer, String> record) {
+            store.put(
+                record.key(),
+                ValueAndTimestamp.make(record.value(), record.timestamp()));
+            context.forward(record);
+        }
+    }
+}
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/query/TimestampedKeyWithHeadersQuery.java
 
b/streams/src/main/java/org/apache/kafka/streams/query/TimestampedKeyWithHeadersQuery.java
new file mode 100644
index 00000000000..cbcca190385
--- /dev/null
+++ 
b/streams/src/main/java/org/apache/kafka/streams/query/TimestampedKeyWithHeadersQuery.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.query;
+
+import org.apache.kafka.common.annotation.InterfaceAudience;
+import org.apache.kafka.common.annotation.InterfaceStability.Evolving;
+import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
+import org.apache.kafka.streams.state.TimestampedKeyValueStoreWithHeaders;
+
+import java.util.Objects;
+
+/**
+ * Interactive query for retrieving a single record, including its record 
headers, based on its key
+ * from a {@link TimestampedKeyValueStoreWithHeaders}.
+ *
+ * <p>This is the headers-aware parallel of {@link TimestampedKeyQuery}: it 
returns a
+ * {@link ReadOnlyRecord} carrying the key, value, timestamp, and headers, 
whereas
+ * {@link TimestampedKeyQuery} returns a {@link 
org.apache.kafka.streams.state.ValueAndTimestamp}
+ * (value and timestamp only, no key or headers).
+ *
+ * <p>Headers are persisted and returned only when the store is backed by a 
native headers store,
+ * i.e. built with a KIP-1271 {@code WithHeaders} byte-store supplier (e.g.
+ * {@code Stores.persistentTimestampedKeyValueStoreWithHeaders}). A {@code 
WithHeaders} store built
+ * over a legacy (non-headers) supplier cannot persist headers, so the result 
depends on who serves
+ * the read: with caching enabled, a not-yet-evicted entry is served from the 
record cache and
+ * returns the written value, timestamp, and headers (read-your-writes); once 
served from the
+ * underlying store, the headers are gone. Over a timestamped legacy supplier 
(e.g.
+ * {@code Stores.persistentTimestampedKeyValueStore}) the store-served read 
succeeds with empty
+ * {@code headers()}; over a plain legacy supplier (e.g. {@code 
Stores.persistentKeyValueStore}),
+ * which keeps no timestamp either, the store-served read has no representable 
timestamp and the
+ * query fails with {@link FailureReason#STORE_EXCEPTION}.
+ *
+ * <p>Against a plain store not built with the {@code WithHeaders} builder at 
all, this query type is
+ * unsupported and fails with {@link FailureReason#UNKNOWN_QUERY_TYPE}.
+ *
+ * @param <K> Type of keys
+ * @param <V> Type of values
+ */
+@Evolving
[email protected]
+public final class TimestampedKeyWithHeadersQuery<K, V> implements 
Query<ReadOnlyRecord<K, V>> {
+
+    private final K key;
+    private final boolean skipCache;
+
+    private TimestampedKeyWithHeadersQuery(final K key, final boolean 
skipCache) {
+        this.key = key;
+        this.skipCache = skipCache;
+    }
+
+    /**
+     * Creates a query that will retrieve the record (value, timestamp, and 
headers) identified by
+     * {@code key} if it exists (or {@code null} otherwise).
+     * @param key The key to retrieve
+     * @param <K> The type of the key
+     * @param <V> The type of the value that will be retrieved
+     */
+    public static <K, V> TimestampedKeyWithHeadersQuery<K, V> withKey(final K 
key) {
+        Objects.requireNonNull(key, "the key should not be null");
+        return new TimestampedKeyWithHeadersQuery<>(key, false);
+    }
+
+    /**
+     * Specifies that the cache should be skipped during query evaluation. 
This means, that the query will always
+     * get forwarded to the underlying store.
+     */
+    public TimestampedKeyWithHeadersQuery<K, V> skipCache() {
+        return new TimestampedKeyWithHeadersQuery<>(key, true);
+    }
+
+    /**
+     * Return the key that was specified for this query.
+     *
+     * @return The key that was specified for this query.
+     */
+    public K key() {
+        return key;
+    }
+
+    /**
+     * The flag whether to skip the cache or not during query evaluation.
+     */
+    public boolean isSkipCache() {
+        return skipCache;
+    }
+}
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/CachingKeyValueStoreWithHeaders.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/CachingKeyValueStoreWithHeaders.java
index 47c9c10c45c..f2609803ed7 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/CachingKeyValueStoreWithHeaders.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/CachingKeyValueStoreWithHeaders.java
@@ -18,26 +18,20 @@ package org.apache.kafka.streams.state.internals;
 
 
 import org.apache.kafka.common.utils.Bytes;
-import org.apache.kafka.streams.query.PositionBound;
-import org.apache.kafka.streams.query.Query;
-import org.apache.kafka.streams.query.QueryConfig;
-import org.apache.kafka.streams.query.QueryResult;
 import org.apache.kafka.streams.state.KeyValueStore;
 
 /**
- * A caching key-value store with headers is a caching key-value store that 
only forwards the query to the 
- * wrapped store.
+ * A caching key-value store with headers.
+ *
+ * <p>It inherits {@link CachingKeyValueStore}'s IQv2 {@code query(...)} 
handling: point queries
+ * ({@code KeyQuery}) consult the record cache (honoring {@code skipCache}) 
and fall back to the
+ * wrapped store on a miss, while other query types are forwarded to the 
wrapped store. The cached
+ * value bytes are the serialized {@code ValueTimestampHeaders}; the metered 
layer performs the
+ * header-aware deserialization, so no override is needed here.
  */
 public class CachingKeyValueStoreWithHeaders extends CachingKeyValueStore {
 
     CachingKeyValueStoreWithHeaders(final KeyValueStore<Bytes, byte[]> 
underlying) {
         super(underlying, CacheType.TIMESTAMPED_KEY_VALUE_STORE_WITH_HEADERS);
     }
-
-    @Override
-    public <R> QueryResult<R> query(final Query<R> query,
-                                    final PositionBound positionBound,
-                                    final QueryConfig config) {
-        return wrapped().query(query, positionBound, config);
-    }
 }
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
index 8d10c6ccd25..98b6a024b22 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
@@ -26,8 +26,11 @@ import org.apache.kafka.common.utils.Bytes;
 import org.apache.kafka.common.utils.Time;
 import org.apache.kafka.streams.KeyValue;
 import org.apache.kafka.streams.errors.ProcessorStateException;
+import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
+import org.apache.kafka.streams.processor.api.Record;
 import org.apache.kafka.streams.processor.internals.ProcessorRecordContext;
 import org.apache.kafka.streams.processor.internals.SerdeGetter;
+import org.apache.kafka.streams.query.FailureReason;
 import org.apache.kafka.streams.query.KeyQuery;
 import org.apache.kafka.streams.query.PositionBound;
 import org.apache.kafka.streams.query.Query;
@@ -36,6 +39,7 @@ import org.apache.kafka.streams.query.QueryResult;
 import org.apache.kafka.streams.query.RangeQuery;
 import org.apache.kafka.streams.query.ResultOrder;
 import org.apache.kafka.streams.query.TimestampedKeyQuery;
+import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
 import org.apache.kafka.streams.query.TimestampedRangeQuery;
 import org.apache.kafka.streams.query.internals.InternalQueryResultUtil;
 import org.apache.kafka.streams.state.KeyValueIterator;
@@ -91,6 +95,10 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K, V>
                 TimestampedKeyQuery.class,
                 (query, positionBound, config, store) -> 
runTimestampedKeyQuery(query, positionBound, config)
             ),
+            mkEntry(
+                TimestampedKeyWithHeadersQuery.class,
+                (query, positionBound, config, store) -> 
runTimestampedKeyWithHeadersQuery(query, positionBound, config)
+            ),
             mkEntry(
                 RangeQuery.class,
                 (query, positionBound, config, store) -> runRangeQuery(query, 
positionBound, config)
@@ -286,10 +294,6 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K, 
V>
     /**
      * Executes a query against this store.
      *
-     * <p>Note: Query results do NOT include headers, even though headers are
-     * preserved in the underlying store. This behavior provides compatibility
-     * with existing IQv2 APIs that operate on timestamped stores.
-     *
      * @param query the query to execute
      * @param positionBound the position bound
      * @param config the query configuration
@@ -381,6 +385,72 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K, 
V>
         return result;
     }
 
+    @SuppressWarnings("unchecked")
+    private <R> QueryResult<R> runTimestampedKeyWithHeadersQuery(
+        final Query<R> query,
+        final PositionBound positionBound,
+        final QueryConfig config
+    ) {
+        final QueryResult<R> result;
+        final TimestampedKeyWithHeadersQuery<K, V> typedKeyQuery = 
(TimestampedKeyWithHeadersQuery<K, V>) query;
+
+        // Forward a raw byte-level KeyQuery to the wrapped store, propagating 
skipCache so the caching
+        // layer can honor it; the result bytes are the serialized 
ValueTimestampHeaders, which we
+        // deserialize below to recover value, timestamp, and headers.
+        // The existing KeyQuery/TimestampedKeyQuery handlers do not yet 
propagate skipCache across the
+        // metered stores; that general fix is tracked in KAFKA-20776.
+        KeyQuery<Bytes, byte[]> rawKeyQuery = 
KeyQuery.withKey(serializeKey(typedKeyQuery.key(), internalContext.headers()));
+        if (typedKeyQuery.isSkipCache()) {
+            rawKeyQuery = rawKeyQuery.skipCache();
+        }
+        final QueryResult<byte[]> rawResult = wrapped().query(rawKeyQuery, 
positionBound, config);
+        if (rawResult.isSuccess()) {
+            final Function<byte[], ValueTimestampHeaders<V>> deserializer = 
StoreQueryUtils.deserializeValue(serdes, wrapped());
+            final ValueTimestampHeaders<V> valueTimestampHeaders = 
deserializer.apply(rawResult.getResult());
+            if (valueTimestampHeaders != null && 
valueTimestampHeaders.timestamp() < 0) {
+                // The result is modeled as a Record, whose constructor 
rejects negative timestamps. A
+                // negative stored timestamp cannot arise from the normal 
record-driven flow (the PAPI
+                // Record a processor stores already forbids it), so it 
indicates corrupted/unexpected
+                // store state; surface it as a failed result rather than 
letting `new Record<>` throw
+                // out of query().
+                final QueryResult<ReadOnlyRecord<K, V>> failure = 
QueryResult.forFailure(
+                    FailureReason.STORE_EXCEPTION,
+                    "Stored record for the queried key has a negative 
timestamp ("
+                        + valueTimestampHeaders.timestamp() + "); cannot 
construct a ReadOnlyRecord.");
+                // Preserve the wrapped store's execution info (empty unless 
collectExecutionInfo is set),
+                // matching the success path and the raw-failure path below.
+                
rawResult.getExecutionInfo().forEach(failure::addExecutionInfo);
+                failure.setPosition(rawResult.getPosition());
+                result = (QueryResult<R>) failure;
+            } else {
+                // Surface the result as a ReadOnlyRecord (implemented by 
Record), keeping the headers.
+                // A null wrapper means the key is absent or tombstoned, which 
we surface as a null result.
+                final ReadOnlyRecord<K, V> record;
+                if (valueTimestampHeaders == null) {
+                    record = null;
+                } else {
+                    final Record<K, V> headerRecord = new Record<>(
+                        typedKeyQuery.key(),
+                        valueTimestampHeaders.value(),
+                        valueTimestampHeaders.timestamp(),
+                        valueTimestampHeaders.headers());
+                    // An IQ result is a read-only snapshot, so its headers 
should be immutable too.
+                    // Record copies the headers into a RecordHeaders; mark it 
read-only so a caller
+                    // cannot mutate the returned Headers.
+                    ((RecordHeaders) headerRecord.headers()).setReadOnly();
+                    record = headerRecord;
+                }
+                final QueryResult<ReadOnlyRecord<K, V>> typedQueryResult =
+                    
InternalQueryResultUtil.copyAndSubstituteDeserializedResult(rawResult, record);
+                result = (QueryResult<R>) typedQueryResult;
+            }
+        } else {
+            // the generic type doesn't matter, since failed queries have no 
result set.
+            result = (QueryResult<R>) rawResult;
+        }
+        return result;
+    }
+
     @SuppressWarnings("unchecked")
     private <R> QueryResult<R> runRangeQuery(
         final Query<R> query,
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
index 8f96b182916..d0ad49acfcd 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
@@ -18,6 +18,7 @@
 package org.apache.kafka.streams.state.internals;
 
 import org.apache.kafka.streams.errors.ProcessorStateException;
+import org.apache.kafka.streams.query.KeyQuery;
 import org.apache.kafka.streams.query.PositionBound;
 import org.apache.kafka.streams.query.Query;
 import org.apache.kafka.streams.query.QueryConfig;
@@ -199,6 +200,14 @@ public class RocksDBTimestampedStoreWithHeaders extends 
RocksDBStore implements
     public <R> QueryResult<R> query(final Query<R> query,
                                     final PositionBound positionBound,
                                     final QueryConfig config) {
+        // KIP-1356 (point query only): the headers-aware 
TimestampedKeyWithHeadersQuery forwards a raw
+        // KeyQuery to this native store, so enable KeyQuery via the inherited 
RocksDBStore handling.
+        // Every other query type (e.g. RangeQuery) stays UNKNOWN_QUERY_TYPE 
here, unchanged from before;
+        // those are enabled by their own KIP-1356 follow-ups.
+        if (query instanceof KeyQuery) {
+            return super.query(query, positionBound, config);
+        }
+
         final long start = config.isCollectExecutionInfo() ? System.nanoTime() 
: -1L;
         final QueryResult<R> result;
 
@@ -215,4 +224,4 @@ public class RocksDBTimestampedStoreWithHeaders extends 
RocksDBStore implements
         return result;
     }
 
-}
\ No newline at end of file
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
index f6355e7bd6a..568a11a7226 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
@@ -36,6 +36,12 @@ import org.apache.kafka.streams.processor.TaskId;
 import org.apache.kafka.streams.processor.internals.InternalProcessorContext;
 import org.apache.kafka.streams.processor.internals.ProcessorStateManager;
 import org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl;
+import org.apache.kafka.streams.query.KeyQuery;
+import org.apache.kafka.streams.query.PositionBound;
+import org.apache.kafka.streams.query.Query;
+import org.apache.kafka.streams.query.QueryConfig;
+import org.apache.kafka.streams.query.QueryResult;
+import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
 import org.apache.kafka.streams.state.KeyValueIterator;
 import org.apache.kafka.streams.state.KeyValueStore;
 import org.apache.kafka.streams.state.ValueTimestampHeaders;
@@ -43,6 +49,7 @@ import org.apache.kafka.test.KeyValueIteratorStub;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
@@ -143,6 +150,33 @@ public class 
MeteredTimestampedKeyValueStoreWithHeadersTest {
         metered.init(context, metered);
     }
 
+    // --- skipCache propagation: the TimestampedKeyWithHeadersQuery handler 
must forward isSkipCache()
+    //     onto the raw KeyQuery it builds, otherwise the caching layer never 
sees it. ---
+
+    @Test
+    public void shouldPropagateSkipCacheForTimestampedKeyWithHeadersQuery() {
+        setUp();
+        init();
+        
assertTrue(forwardedRawKeyQuery(TimestampedKeyWithHeadersQuery.withKey(KEY).skipCache()).isSkipCache());
+    }
+
+    @Test
+    public void shouldNotSkipCacheForTimestampedKeyWithHeadersQueryByDefault() 
{
+        setUp();
+        init();
+        
assertFalse(forwardedRawKeyQuery(TimestampedKeyWithHeadersQuery.withKey(KEY)).isSkipCache());
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private KeyQuery<?, ?> forwardedRawKeyQuery(final Query<?> query) {
+        when(inner.query(any(), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn((QueryResult) QueryResult.forResult(null));
+        metered.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+        final ArgumentCaptor<KeyQuery> captor = 
ArgumentCaptor.forClass(KeyQuery.class);
+        verify(inner).query(captor.capture(), any(PositionBound.class), 
any(QueryConfig.class));
+        return captor.getValue();
+    }
+
     @Test
     public void shouldDelegateInit() {
         setUp();
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
index d098696ded7..be048ece610 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
@@ -25,8 +25,10 @@ import 
org.apache.kafka.streams.errors.ProcessorStateException;
 import org.apache.kafka.streams.query.FailureReason;
 import org.apache.kafka.streams.query.KeyQuery;
 import org.apache.kafka.streams.query.PositionBound;
+import org.apache.kafka.streams.query.Query;
 import org.apache.kafka.streams.query.QueryConfig;
 import org.apache.kafka.streams.query.QueryResult;
+import org.apache.kafka.streams.query.RangeQuery;
 import org.apache.kafka.streams.state.KeyValueIterator;
 
 import org.junit.jupiter.api.Test;
@@ -981,17 +983,17 @@ public class RocksDBTimestampedStoreWithHeadersTest 
extends RocksDBStoreTest {
     }
 
     @Test
-    public void shouldReturnUnknownQueryTypeForQuery() {
+    public void shouldReturnUnknownQueryTypeForUnsupportedQuery() {
         // Initialize the store
         rocksDBStore.init(context, rocksDBStore);
 
-        // Create a query
-        final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(new 
Bytes("test-key".getBytes()));
+        // A query type the store has no handler for still yields 
UNKNOWN_QUERY_TYPE.
+        final Query<Void> unsupportedQuery = new Query<>() { };
         final PositionBound positionBound = PositionBound.unbounded();
         final QueryConfig config = new QueryConfig(false);
 
         // Execute query
-        final QueryResult<byte[]> result = rocksDBStore.query(query, 
positionBound, config);
+        final QueryResult<Void> result = rocksDBStore.query(unsupportedQuery, 
positionBound, config);
 
         // Verify result indicates unknown query type
         assertFalse(result.isSuccess(), "Expected query to fail with unknown 
query type");
@@ -1005,6 +1007,49 @@ public class RocksDBTimestampedStoreWithHeadersTest 
extends RocksDBStoreTest {
         assertNotNull(result.getPosition(), "Expected position to be set");
     }
 
+    @Test
+    public void shouldHandleKeyQuery() {
+        // Initialize the store
+        rocksDBStore.init(context, rocksDBStore);
+
+        // Store raw (serialized ValueTimestampHeaders) bytes for a key.
+        final Bytes key = new Bytes("test-key".getBytes());
+        final byte[] storedBytes = "headers+timestamp+value".getBytes();
+        rocksDBStore.put(key, storedBytes);
+
+        // KIP-1356: removing the query() override lets the native store serve 
KeyQuery (previously
+        // UNKNOWN_QUERY_TYPE), matching the adapter build path. The raw 
stored bytes are returned;
+        // the metered store performs the header-aware deserialization.
+        final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(key);
+        final QueryResult<byte[]> result =
+            rocksDBStore.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+
+        assertTrue(result.isSuccess(), "Expected KeyQuery to succeed");
+        assertArrayEquals(storedBytes, result.getResult(), "Expected the raw 
stored bytes to be returned");
+        assertNotNull(result.getPosition(), "Expected position to be set");
+    }
+
+    @Test
+    public void shouldReturnUnknownQueryTypeForRangeQuery() {
+        // Initialize the store
+        rocksDBStore.init(context, rocksDBStore);
+
+        // KIP-1356 (point query only): this store enables KeyQuery but leaves 
RangeQuery (and every
+        // other non-KeyQuery type) as UNKNOWN_QUERY_TYPE, unchanged from 
before. RangeQuery support on
+        // the native header store is deferred to the range KIP-1356 follow-up.
+        final RangeQuery<Bytes, byte[]> query = RangeQuery.withNoBounds();
+        final QueryResult<KeyValueIterator<Bytes, byte[]>> result =
+                rocksDBStore.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+
+        assertFalse(result.isSuccess(), "Expected RangeQuery to be 
unsupported");
+        assertEquals(
+            FailureReason.UNKNOWN_QUERY_TYPE,
+            result.getFailureReason(),
+            "Expected UNKNOWN_QUERY_TYPE failure reason"
+        );
+        assertNotNull(result.getPosition(), "Expected position to be set");
+    }
+
     @Test
     public void shouldCollectExecutionInfoWhenRequested() {
         // Initialize the store
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
index 3c150da4f3d..cccc1174e53 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
@@ -19,11 +19,15 @@ package org.apache.kafka.streams.state.internals;
 
 import org.apache.kafka.common.header.Headers;
 import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.metrics.Metrics;
 import org.apache.kafka.common.serialization.Serdes;
 import org.apache.kafka.common.utils.Bytes;
 import org.apache.kafka.common.utils.MockTime;
-import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.common.utils.internals.LogContext;
 import org.apache.kafka.streams.processor.StateStore;
+import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
+import org.apache.kafka.streams.processor.internals.MockStreamsMetrics;
+import org.apache.kafka.streams.processor.internals.ProcessorRecordContext;
 import org.apache.kafka.streams.query.FailureReason;
 import org.apache.kafka.streams.query.KeyQuery;
 import org.apache.kafka.streams.query.Position;
@@ -32,29 +36,27 @@ import org.apache.kafka.streams.query.QueryConfig;
 import org.apache.kafka.streams.query.QueryResult;
 import org.apache.kafka.streams.query.RangeQuery;
 import org.apache.kafka.streams.query.TimestampedKeyQuery;
-import org.apache.kafka.streams.state.HeadersBytesStore;
+import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
 import org.apache.kafka.streams.state.KeyValueBytesStoreSupplier;
-import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.KeyValueStore;
 import org.apache.kafka.streams.state.TimestampedKeyValueStoreWithHeaders;
 import org.apache.kafka.streams.state.ValueAndTimestamp;
 import org.apache.kafka.streams.state.ValueTimestampHeaders;
 import org.apache.kafka.test.InternalMockProcessorContext;
-import org.apache.kafka.test.StreamsTestUtils;
 import org.apache.kafka.test.TestUtils;
 
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
-import java.io.File;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
-import java.util.Properties;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -62,9 +64,7 @@ import static 
org.junit.jupiter.api.Assertions.assertInstanceOf;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.lenient;
-import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
 @ExtendWith(MockitoExtension.class)
@@ -228,356 +228,271 @@ public class 
TimestampedKeyValueStoreBuilderWithHeadersTest {
         assertInstanceOf(PlainToHeadersStoreAdapter.class, 
((WrappedStateStore) store).wrapped());
     }
 
-    @Test
-    public void shouldHandleKeyQueryOnInMemoryStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("in-memory");
-        when(supplier.get()).thenReturn(new 
InMemoryKeyValueStore("test-store"));
+    // 
----------------------------------------------------------------------------------------------
+    // IQv2 query handling for the built header store.
+    //
+    // The header store can be built three ways; each is exercised with 
caching on and off through a
+    // single helper that builds the real store chain (Metered -> [Caching] -> 
inner) with a real
+    // record cache, then writes and reads real data at the typed (metered) 
level:
+    //   NATIVE    -> RocksDBTimestampedStoreWithHeaders                       
(persists headers)
+    //   ADAPTER   -> RocksDBTimestampedStore via 
TimestampedToHeadersStoreAdapter (drops headers)
+    //   IN_MEMORY -> InMemoryKeyValueStore via the in-memory headers marker
+    // 
----------------------------------------------------------------------------------------------
+
+    private enum StoreType { NATIVE, ADAPTER, PLAIN_ADAPTER, IN_MEMORY }
+
+    private KeyValueStore<Bytes, byte[]> innerStore(final StoreType storeType) 
{
+        switch (storeType) {
+            case NATIVE:    return new 
RocksDBTimestampedStoreWithHeaders("test-store", "metrics-scope");
+            case ADAPTER:   return new RocksDBTimestampedStore("test-store", 
"metrics-scope");
+            case PLAIN_ADAPTER: return new RocksDBStore("test-store", 
"metrics-scope");
+            case IN_MEMORY: return new InMemoryKeyValueStore("test-store");
+            default:        throw new IllegalArgumentException("unknown store 
type: " + storeType);
+        }
+    }
 
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
+    private TimestampedKeyValueStoreWithHeaders<String, String> 
buildAndInitStore(
+            final StoreType storeType,
+            final boolean cachingEnabled) {
+        lenient().when(supplier.name()).thenReturn("test-store");
+        lenient().when(supplier.metricsScope()).thenReturn("metricScope");
+        lenient().when(supplier.get()).thenReturn(innerStore(storeType));
 
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
+        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
+            supplier, Serdes.String(), Serdes.String(), new MockTime());
+        final TimestampedKeyValueStoreWithHeaders<String, String> store =
+            (cachingEnabled
+                ? builder.withLoggingDisabled().withCachingEnabled()
+                : builder.withLoggingDisabled().withCachingDisabled())
+                .build();
 
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
+        final ThreadCache cache = new ThreadCache(
+            new LogContext("test "),
+            cachingEnabled ? 10 * 1024 * 1024 : 0,
+            new MockStreamsMetrics(new Metrics()));
         final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
+            TestUtils.tempDirectory(), Serdes.String(), Serdes.String(), null, 
cache);
+        context.setRecordContext(new ProcessorRecordContext(0L, 0L, 0, 
"topic", new RecordHeaders()));
         store.init(context, store);
+        return store;
+    }
 
-        try {
-            // Put data into the store
-            final Headers headers = new RecordHeaders();
-            headers.add("key1", "value1".getBytes());
-            store.put("test-key", ValueTimestampHeaders.make("test-value", 
12345L, headers));
+    private static Headers headersWith(final String key, final String value) {
+        return new RecordHeaders().add(key, 
value.getBytes(StandardCharsets.UTF_8));
+    }
 
-            // Verify wrapper type for InMemoryKeyValueStore
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            assertInstanceOf(HeadersBytesStore.class, wrapped,
-                "Expected wrapper to implement HeadersBytesStore for 
InMemoryKeyValueStore");
+    @ParameterizedTest
+    @CsvSource({"NATIVE, true", "NATIVE, false", "ADAPTER, true", "ADAPTER, 
false", "IN_MEMORY, true", "IN_MEMORY, false"})
+    public void shouldHandleKeyQuery(final StoreType storeType, final boolean 
cachingEnabled) {
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, cachingEnabled);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
 
-            // Query at typed level - KeyQuery should return just the value
-            final KeyQuery<String, String> query = 
KeyQuery.withKey("test-key");
-            final QueryResult<String> result = store.query(query, 
PositionBound.unbounded(), new QueryConfig(false));
+            final QueryResult<String> result =
+                store.query(KeyQuery.withKey("k"), PositionBound.unbounded(), 
new QueryConfig(false));
 
-            // Verify IQv2 query result
-            assertTrue(result.isSuccess(), "Expected query to succeed on 
InMemoryKeyValueStore");
+            assertTrue(result.isSuccess(), "Expected KeyQuery to succeed");
+            assertEquals("v", result.getResult(), "KeyQuery returns the value 
only");
             assertNotNull(result.getPosition(), "Expected position to be set");
-            assertInstanceOf(String.class, result.getResult());
-            assertEquals("test-value", result.getResult(), "KeyQuery should 
return just the value");
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldHandleTimestampedKeyQueryOnInMemoryStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("in-memory");
-        when(supplier.get()).thenReturn(new 
InMemoryKeyValueStore("test-store"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    @ParameterizedTest
+    @CsvSource({"NATIVE, true", "NATIVE, false", "ADAPTER, true", "ADAPTER, 
false", "IN_MEMORY, true", "IN_MEMORY, false"})
+    public void shouldHandleTimestampedKeyQuery(final StoreType storeType, 
final boolean cachingEnabled) {
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, cachingEnabled);
         try {
-            // Put data into the store
-            final Headers headers = new RecordHeaders();
-            headers.add("key1", "value1".getBytes());
-            store.put("test-key", ValueTimestampHeaders.make("test-value", 
12345L, headers));
-
-            // Verify wrapper type for InMemoryKeyValueStore
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            assertInstanceOf(HeadersBytesStore.class, wrapped,
-                "Expected wrapper to implement HeadersBytesStore for 
InMemoryKeyValueStore");
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
 
-            // Query at typed level - TimestampedKeyQuery should return value 
+ timestamp
-            final TimestampedKeyQuery<String, String> query = 
TimestampedKeyQuery.withKey("test-key");
-            final QueryResult<ValueAndTimestamp<String>> result = 
store.query(query, PositionBound.unbounded(), new QueryConfig(false));
+            final QueryResult<ValueAndTimestamp<String>> result =
+                store.query(TimestampedKeyQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false));
 
-            // Verify IQv2 query result
-            assertTrue(result.isSuccess(), "Expected query to succeed on 
InMemoryKeyValueStore");
+            assertTrue(result.isSuccess(), "Expected TimestampedKeyQuery to 
succeed");
+            assertEquals("v", result.getResult().value());
+            assertEquals(123L, result.getResult().timestamp());
             assertNotNull(result.getPosition(), "Expected position to be set");
-            assertNotNull(result.getResult(), "Expected non-null result");
-            assertInstanceOf(ValueAndTimestamp.class, result.getResult());
-            assertEquals("test-value", result.getResult().value(), 
"TimestampedKeyQuery should return the value");
-            assertEquals(12345L, result.getResult().timestamp(), 
"TimestampedKeyQuery should return the timestamp");
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldReturnPositionFromHeadersStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStoreWithHeaders("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    @ParameterizedTest
+    @CsvSource({"NATIVE, true", "NATIVE, false", "IN_MEMORY, true", 
"IN_MEMORY, false"})
+    public void 
shouldReturnHeadersForTimestampedKeyWithHeadersQueryOnHeaderPersistingStore(
+            final StoreType storeType, final boolean cachingEnabled) {
+        // The native and in-memory builds both persist headers: the native 
store keeps them in its
+        // headers column family, and the in-memory marker stores the value 
bytes verbatim (the metered
+        // layer serializes the headers into those bytes). The adapter build 
is the one that drops them.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, cachingEnabled);
         try {
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            final Position position = wrapped.getPosition();
-
-            // Verify: Position is returned (should be non-null)
-            assertNotNull(position, "Expected non-null position");
-            assertTrue(position.isEmpty(), "Expected position to be empty 
initially");
+            final Headers headers = headersWith("h", "x");
+            store.put("k", ValueTimestampHeaders.make("v", 123L, headers));
+
+            final QueryResult<ReadOnlyRecord<String, String>> result =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess(), "Expected 
TimestampedKeyWithHeadersQuery to succeed");
+            final ReadOnlyRecord<String, String> record = result.getResult();
+            assertEquals("k", record.key());
+            assertEquals("v", record.value());
+            assertEquals(123L, record.timestamp());
+            // Headers must round-trip on both the persistent path and the 
cache path.
+            assertEquals(headers, record.headers());
+            // The IQ result is a read-only snapshot: its headers are 
immutable.
+            assertThrows(IllegalStateException.class, () -> 
record.headers().add("new", new byte[0]),
+                "IQ result headers should be read-only");
+            assertNotNull(result.getPosition(), "Expected position to be set");
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldReturnPositionFromAdaptedTimestampedStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStore("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
shouldReflectFlushTimingForTimestampedKeyWithHeadersQueryOnAdapterStore(final 
boolean cachingEnabled) {
+        // Feeding a non-header supplier into the WithHeaders builder yields 
an adapter-built store
+        // (TimestampedToHeadersStoreAdapter over a plain timestamped store), 
which cannot persist
+        // headers. The metered layer still serializes headers into the value 
bytes, and the record
+        // cache sits above the adapter, so the header result depends on who 
serves the read:
+        //   - store-served (caching disabled, or -- when caching is enabled 
-- once the entry has been
+        //     evicted/flushed out of the cache): the read goes through the 
adapter, which stripped the
+        //     headers on write, so they come back empty (never null) even 
though they were written;
+        //   - cache-served (caching enabled, entry still warm): returned with 
headers intact
+        //     (read-your-writes), since the cache holds the full serialized 
bytes.
+        // The cache is the only thing preserving headers on this build. The 
caching-disabled run reads
+        // through the same adapter a post-eviction cache miss would, so it 
covers the store-served
+        // (empty) outcome; the caching-enabled run covers the cache-served 
(headers) outcome and then
+        // pins the flush-dependent flip on the same instance: after flushing 
the cache, a skipCache read
+        // (which goes to the store, bypassing the cache) returns empty 
headers.
+        // value/timestamp round-trip either way.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.ADAPTER, cachingEnabled);
         try {
-            // Verify adapter is used
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            assertInstanceOf(TimestampedToHeadersStoreAdapter.class, wrapped);
-
-            // Get position from adapter (should delegate to underlying store)
-            final Position position = wrapped.getPosition();
+            final Headers headers = headersWith("h", "x");
+            store.put("k", ValueTimestampHeaders.make("v", 123L, headers));
+
+            final QueryResult<ReadOnlyRecord<String, String>> result =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess(), "Expected 
TimestampedKeyWithHeadersQuery to succeed on an adapter-built store");
+            final ReadOnlyRecord<String, String> record = result.getResult();
+            assertEquals("k", record.key());
+            assertEquals("v", record.value());
+            assertEquals(123L, record.timestamp());
+            // Cache-served (caching enabled, warm) -> written headers; 
store-served (caching disabled,
+            // or a post-eviction cache miss) -> empty, since the adapter 
stripped them on write.
+            final Headers expectedHeaders = cachingEnabled ? headers : new 
RecordHeaders();
+            assertEquals(expectedHeaders, record.headers());
+            assertNotNull(result.getPosition(), "Expected position to be set");
 
-            assertNotNull(position, "Expected non-null position from adapter");
-            assertTrue(position.isEmpty(), "Expected position to be empty 
initially");
+            if (cachingEnabled) {
+                // Same instance, flush-dependent flip: flush the record cache 
down through the
+                // (header-stripping) adapter to the persistent store, then 
read past the cache with
+                // skipCache. The persistent copy has no headers, so the same 
key now comes back empty
+                // (value/timestamp still round-trip; the timestamped adapter 
keeps the timestamp).
+                ((CachedStateStore<?, ?>) ((WrappedStateStore) 
store).wrapped()).flushCache();
+                final QueryResult<ReadOnlyRecord<String, String>> afterFlush = 
store.query(
+                    TimestampedKeyWithHeadersQuery.<String, 
String>withKey("k").skipCache(),
+                    PositionBound.unbounded(),
+                    new QueryConfig(false));
+                assertTrue(afterFlush.isSuccess(), "Expected skipCache read to 
succeed after flush");
+                assertEquals("v", afterFlush.getResult().value());
+                assertEquals(123L, afterFlush.getResult().timestamp());
+                assertEquals(new RecordHeaders(), 
afterFlush.getResult().headers());
+            }
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldReturnPositionFromInMemoryStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("in-memory");
-        when(supplier.get()).thenReturn(new 
InMemoryKeyValueStore("test-store"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
shouldReflectFlushTimingForTimestampedKeyWithHeadersQueryOnPlainLegacyStore(final
 boolean cachingEnabled) {
+        // A plain (non-timestamped) legacy supplier is wrapped in 
PlainToHeadersStoreAdapter, which can
+        // persist neither headers nor a timestamp: on write it keeps only the 
raw value, and on read it
+        // rebuilds the header format with empty headers and timestamp = -1. 
So the result depends on who
+        // serves the read:
+        //   - cache-served (caching enabled, entry still warm): the cache 
holds the full serialized bytes
+        //     the metered layer wrote, so the query succeeds with the real 
value, timestamp, and headers;
+        //   - store-served (caching disabled, or once the entry is 
evicted/flushed): the value comes back
+        //     with timestamp -1, which cannot be represented as a Record, so 
the query fails with
+        //     STORE_EXCEPTION -- unlike the timestamped adapter (which keeps 
the timestamp and just
+        //     returns empty headers), a plain store loses the timestamp too.
+        // The caching-enabled run also pins the flip on the same instance: 
after flushing the cache, a
+        // skipCache read goes to the store and fails with STORE_EXCEPTION.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.PLAIN_ADAPTER, cachingEnabled);
         try {
-            // Verify marker wrapper is used
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            assertInstanceOf(HeadersBytesStore.class, wrapped);
-
-            // Get position from marker (should delegate to 
InMemoryKeyValueStore)
-            final Position position = wrapped.getPosition();
-
-            assertNotNull(position, "Expected non-null position from in-memory 
store");
-            assertTrue(position.isEmpty(), "Expected position to be empty 
initially");
+            final Headers headers = headersWith("h", "x");
+            store.put("k", ValueTimestampHeaders.make("v", 123L, headers));
+
+            final QueryResult<ReadOnlyRecord<String, String>> result =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            if (cachingEnabled) {
+                assertTrue(result.isSuccess(), "Expected a cache-served read 
to succeed");
+                final ReadOnlyRecord<String, String> record = 
result.getResult();
+                assertEquals("k", record.key());
+                assertEquals("v", record.value());
+                assertEquals(123L, record.timestamp());
+                assertEquals(headers, record.headers());
+
+                // Same instance, flush-dependent flip: flush the cache down 
through the plain adapter
+                // (which keeps only the raw value) to the persistent store, 
then read past the cache with
+                // skipCache. The store read rebuilds a header record with 
timestamp -1, which cannot be a
+                // Record, so the same key now fails with STORE_EXCEPTION.
+                ((CachedStateStore<?, ?>) ((WrappedStateStore) 
store).wrapped()).flushCache();
+                final QueryResult<ReadOnlyRecord<String, String>> afterFlush = 
store.query(
+                    TimestampedKeyWithHeadersQuery.<String, 
String>withKey("k").skipCache(),
+                    PositionBound.unbounded(),
+                    new QueryConfig(false));
+                assertFalse(afterFlush.isSuccess(),
+                    "A skipCache read after flush hits the store (ts=-1) and 
must fail");
+                assertEquals(FailureReason.STORE_EXCEPTION, 
afterFlush.getFailureReason());
+            } else {
+                assertFalse(result.isSuccess(),
+                    "A store-served read on a plain build has ts=-1 and must 
fail, not return empty headers");
+                assertEquals(FailureReason.STORE_EXCEPTION, 
result.getFailureReason());
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldMaintainPositionAcrossOperationsOnHeadersStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStoreWithHeaders("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
shouldFailTimestampedKeyWithHeadersQueryForNegativeStoredTimestamp(final 
boolean cachingEnabled) {
+        // A negative stored timestamp can't arise from the normal 
record-driven flow, but a caller can
+        // store one directly. Because the result is modeled as a Record 
(whose constructor rejects
+        // negative timestamps), the query must surface a failed result rather 
than throw out of query().
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, cachingEnabled);
         try {
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-
-            // Get initial position
-            final Position initialPosition = wrapped.getPosition();
-            assertNotNull(initialPosition, "Expected non-null initial 
position");
-
-            // Put some data
-            store.put("key1", ValueTimestampHeaders.make("value1", 100L, new 
RecordHeaders()));
-            store.put("key2", ValueTimestampHeaders.make("value2", 200L, new 
RecordHeaders()));
+            store.put("k", ValueTimestampHeaders.make("v", -1L, 
headersWith("h", "x")));
 
-            // Get position after puts
-            final Position afterPutPosition = wrapped.getPosition();
-            assertNotNull(afterPutPosition, "Expected non-null position after 
puts");
+            final QueryResult<ReadOnlyRecord<String, String>> result =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false));
 
-            // Position object should be the same instance (stores maintain a 
single position)
-            // The position content might be updated internally by the context
+            assertFalse(result.isSuccess(), "A negative stored timestamp 
should yield a failed result, not throw");
+            assertEquals(FailureReason.STORE_EXCEPTION, 
result.getFailureReason());
+            assertNotNull(result.getPosition(), "Expected the failure to 
preserve the queried partition's position");
         } finally {
             store.close();
         }
     }
 
-    private static ThreadCache mockCacheHit() {
-        final ThreadCache cache = mock(ThreadCache.class);
-        final LRUCacheEntry entry = mock(LRUCacheEntry.class);
-        final byte[] entryValue = 
"mockEntryValue".getBytes(StandardCharsets.UTF_8);
-        lenient().when(entry.value()).thenReturn(entryValue);
-        lenient().when(cache.get(any(String.class), 
any(Bytes.class))).thenReturn(entry);
-        return cache;
-    }
-
-    private TimestampedKeyValueStoreWithHeaders<String, String> 
headersStoreMaybeWithCache(final boolean cachingEnabled) {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStoreWithHeaders("test-store", "metrics-scope"));
-
-        final File dir = TestUtils.tempDirectory();
-        final ThreadCache cache = mockCacheHit();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            null,
-            cache
-        );
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-        
-        final TimestampedKeyValueStoreWithHeaders<String, String> store;
-        if (cachingEnabled) {
-            store = builder.withLoggingDisabled()
-                .withCachingEnabled()
-                .build();
-        } else {
-            store = builder.withLoggingDisabled()
-                .withCachingDisabled()
-                .build();
-        }
-
-        store.init(context, store);
-        return store;
-    }
-
     @ParameterizedTest
     @ValueSource(booleans = {true, false})
-    public void shouldReturnUnknownQueryTypeForKeyQueryOnHeadersStore(final 
boolean cachingEnabled) {
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
headersStoreMaybeWithCache(cachingEnabled);
-
+    public void shouldReturnUnknownQueryTypeForRangeQueryOnHeadersStore(final 
boolean cachingEnabled) {
+        // KIP-1356 (point query only): the native header store enables 
KeyQuery but not RangeQuery, so a
+        // RangeQuery reports UNKNOWN_QUERY_TYPE (caching on or off). 
RangeQuery support on the native
+        // build is deferred to the range follow-up.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, cachingEnabled);
         try {
-            final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(new 
Bytes("test-key".getBytes()));
-            final PositionBound positionBound = PositionBound.unbounded();
-            final QueryConfig config = new QueryConfig(false);
-
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            final QueryResult<byte[]> result = wrapped.query(query, 
positionBound, config);
-
-            // Verify: Headers store currently returns UNKNOWN_QUERY_TYPE
-            assertFalse(result.isSuccess(), "Expected query to fail with 
unknown query type");
-            assertEquals(
-                FailureReason.UNKNOWN_QUERY_TYPE,
-                result.getFailureReason(),
-                "Expected UNKNOWN_QUERY_TYPE failure reason"
-            );
+            final QueryResult<?> result =
+                store.query(RangeQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+            assertFalse(result.isSuccess(), "Expected RangeQuery to be 
unsupported on the native header store");
+            assertEquals(FailureReason.UNKNOWN_QUERY_TYPE, 
result.getFailureReason());
             assertNotNull(result.getPosition(), "Expected position to be set");
         } finally {
             store.close();
@@ -585,248 +500,119 @@ public class 
TimestampedKeyValueStoreBuilderWithHeadersTest {
     }
 
     @ParameterizedTest
-    @ValueSource(booleans = {true, false})
-    public void shouldReturnUnknownQueryTypeForRangeQueryOnHeadersStore(final 
boolean cachingEnabled) {
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
headersStoreMaybeWithCache(cachingEnabled);
-
+    @CsvSource({"NATIVE", "ADAPTER", "IN_MEMORY"})
+    public void shouldReturnEmptyPositionInitially(final StoreType storeType) {
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, false);
         try {
-            final RangeQuery<Bytes, byte[]> query = RangeQuery.withRange(
-                new Bytes("a".getBytes()),
-                new Bytes("z".getBytes())
-            );
-            final PositionBound positionBound = PositionBound.unbounded();
-            final QueryConfig config = new QueryConfig(false);
-
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            final QueryResult<KeyValueIterator<Bytes, byte[]>> result = 
wrapped.query(query, positionBound, config);
-
-            // Verify: Headers store currently returns UNKNOWN_QUERY_TYPE
-            assertFalse(result.isSuccess(), "Expected query to fail with 
unknown query type");
-            assertEquals(
-                FailureReason.UNKNOWN_QUERY_TYPE,
-                result.getFailureReason(),
-                "Expected UNKNOWN_QUERY_TYPE failure reason"
-            );
-            assertNotNull(result.getPosition(), "Expected position to be set");
+            final Position position = ((WrappedStateStore) 
store).wrapped().getPosition();
+            assertNotNull(position, "Expected non-null position");
+            assertTrue(position.isEmpty(), "Expected position to be empty 
initially");
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldCollectExecutionInfoForQueryOnHeadersStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStoreWithHeaders("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    @ParameterizedTest
+    @CsvSource({"NATIVE", "ADAPTER"})
+    public void shouldCollectExecutionInfoWhenRequested(final StoreType 
storeType) {
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, false);
         try {
-            final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(new 
Bytes("test-key".getBytes()));
-            final PositionBound positionBound = PositionBound.unbounded();
-            final QueryConfig config = new QueryConfig(true); // Enable 
execution info
-
             final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            final QueryResult<byte[]> result = wrapped.query(query, 
positionBound, config);
+            final QueryResult<byte[]> result = wrapped.query(
+                KeyQuery.withKey(new Bytes("k".getBytes())), 
PositionBound.unbounded(), new QueryConfig(true));
 
-            // Verify: Execution info was collected
-            assertFalse(result.getExecutionInfo().isEmpty(), "Expected 
execution info to be collected");
-            assertTrue(
-                result.getExecutionInfo().get(0).contains("Handled in"),
-                "Expected execution info to contain handling information"
-            );
-            assertTrue(
-                
result.getExecutionInfo().get(0).contains(RocksDBTimestampedStoreWithHeaders.class.getName()),
-                "Expected execution info to mention the class name"
-            );
+            final String executionInfo = String.join("\n", 
result.getExecutionInfo());
+            assertFalse(executionInfo.isEmpty(), "Expected execution info to 
be collected");
+            assertTrue(executionInfo.contains("Handled in"), "Expected 
execution info to contain handling information");
+            final String expectedClass = storeType == StoreType.NATIVE
+                ? RocksDBTimestampedStoreWithHeaders.class.getName()
+                : TimestampedToHeadersStoreAdapter.class.getName();
+            assertTrue(executionInfo.contains(expectedClass), "Expected 
execution info to mention " + expectedClass);
         } finally {
             store.close();
         }
     }
 
     @Test
-    public void shouldHandleKeyQueryOnAdaptedTimestampedStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStore("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    public void 
shouldCollectExecutionInfoForTimestampedKeyWithHeadersQueryWhenRequested() {
+        // The typed query, run through the metered handler with execution 
info enabled, must carry both
+        // the wrapped store's entry and the metered handler's entry -- on the 
success path and on the
+        // negative-timestamp failure path (which builds a fresh failure 
result).
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, false);
         try {
-            // Put data into the store (headers will be discarded when adapted 
to timestamped store)
-            final Headers headers = new RecordHeaders();
-            headers.add("adapter", "test".getBytes());
-            store.put("test-key", ValueTimestampHeaders.make("adapter-value", 
55555L, headers));
+            store.put("ok", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+            store.put("bad", ValueTimestampHeaders.make("v", -1L, 
headersWith("h", "x")));
 
-            // Verify adapter is used for legacy timestamped store
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            assertInstanceOf(TimestampedToHeadersStoreAdapter.class, wrapped,
-                "Expected TimestampedToHeadersStoreAdapter for legacy 
timestamped store");
+            final QueryResult<ReadOnlyRecord<String, String>> ok =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("ok"), 
PositionBound.unbounded(), new QueryConfig(true));
+            final QueryResult<ReadOnlyRecord<String, String>> bad =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("bad"), 
PositionBound.unbounded(), new QueryConfig(true));
 
-            // Query at typed level - KeyQuery should return just the value
-            final KeyQuery<String, String> query = 
KeyQuery.withKey("test-key");
-            final QueryResult<String> result = store.query(query, 
PositionBound.unbounded(), new QueryConfig(false));
+            assertTrue(ok.isSuccess());
+            assertFalse(bad.isSuccess());
+            assertEquals(FailureReason.STORE_EXCEPTION, 
bad.getFailureReason());
 
-            // Verify IQv2 query result
-            // Adapter delegates to RocksDBTimestampedStore which supports 
IQv2 through RocksDBStore
-            assertTrue(result.isSuccess(),
-                "Expected query to succeed since RocksDBTimestampedStore 
supports IQv2");
-            assertNotNull(result.getPosition(), "Expected position to be set");
-            assertInstanceOf(String.class, result.getResult());
-            assertEquals("adapter-value", result.getResult(), "KeyQuery should 
return just the value");
+            final String okInfo = String.join("\n", ok.getExecutionInfo());
+            final String badInfo = String.join("\n", bad.getExecutionInfo());
+            assertTrue(
+                
okInfo.contains(RocksDBTimestampedStoreWithHeaders.class.getName())
+                    && 
okInfo.contains(MeteredTimestampedKeyValueStoreWithHeaders.class.getName()),
+                "success execution info missing an entry: " + okInfo);
+            assertTrue(
+                
badInfo.contains(RocksDBTimestampedStoreWithHeaders.class.getName())
+                    && 
badInfo.contains(MeteredTimestampedKeyValueStoreWithHeaders.class.getName()),
+                "failure execution info missing an entry (wrapped-store entry 
must be preserved): " + badInfo);
         } finally {
             store.close();
         }
     }
 
     @Test
-    public void shouldHandleTimestampedKeyQueryOnAdaptedTimestampedStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStore("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
-
+    public void 
shouldNotCollectExecutionInfoForTimestampedKeyWithHeadersQueryWhenNotRequested()
 {
+        // With execution info disabled, neither the success path nor the 
negative-timestamp failure path
+        // may collect any (the failure path must not copy execution info 
unconditionally).
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, false);
         try {
-            // Put data into the store (headers will be discarded when adapted 
to timestamped store)
-            final Headers headers = new RecordHeaders();
-            headers.add("adapter", "test".getBytes());
-            store.put("test-key", ValueTimestampHeaders.make("adapter-value", 
55555L, headers));
-
-            // Verify adapter is used for legacy timestamped store
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            assertInstanceOf(TimestampedToHeadersStoreAdapter.class, wrapped,
-                "Expected TimestampedToHeadersStoreAdapter for legacy 
timestamped store");
-
-            // Query at typed level - TimestampedKeyQuery should return value 
+ timestamp
-            final TimestampedKeyQuery<String, String> query = 
TimestampedKeyQuery.withKey("test-key");
-            final QueryResult<ValueAndTimestamp<String>> result = 
store.query(query, PositionBound.unbounded(), new QueryConfig(false));
-
-            // Verify IQv2 query result
-            // Adapter delegates to RocksDBTimestampedStore which supports 
IQv2 through RocksDBStore
-            assertTrue(result.isSuccess(),
-                "Expected query to succeed since RocksDBTimestampedStore 
supports IQv2");
-            assertNotNull(result.getPosition(), "Expected position to be set");
-            assertNotNull(result.getResult(), "Expected non-null result");
-            assertInstanceOf(ValueAndTimestamp.class, result.getResult());
-            assertEquals("adapter-value", result.getResult().value(), 
"TimestampedKeyQuery should return the value");
-            assertEquals(55555L, result.getResult().timestamp(), 
"TimestampedKeyQuery should return the timestamp");
+            store.put("ok", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+            store.put("bad", ValueTimestampHeaders.make("v", -1L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecord<String, String>> ok =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("ok"), 
PositionBound.unbounded(), new QueryConfig(false));
+            final QueryResult<ReadOnlyRecord<String, String>> bad =
+                store.query(TimestampedKeyWithHeadersQuery.withKey("bad"), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(ok.isSuccess());
+            assertFalse(bad.isSuccess());
+            assertTrue(ok.getExecutionInfo().isEmpty(), "Expected no execution 
info on success: " + ok.getExecutionInfo());
+            assertTrue(bad.getExecutionInfo().isEmpty(), "Expected no 
execution info on failure: " + bad.getExecutionInfo());
         } finally {
             store.close();
         }
     }
 
-    @Test
-    public void shouldCollectExecutionInfoForQueryOnAdaptedTimestampedStore() {
-        when(supplier.name()).thenReturn("test-store");
-        when(supplier.metricsScope()).thenReturn("metricScope");
-        when(supplier.get()).thenReturn(new 
RocksDBTimestampedStore("test-store", "metrics-scope"));
-
-        builder = new TimestampedKeyValueStoreBuilderWithHeaders<>(
-            supplier,
-            Serdes.String(),
-            Serdes.String(),
-            new MockTime()
-        );
-
-        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
builder
-            .withLoggingDisabled()
-            .withCachingDisabled()
-            .build();
-
-        final File dir = TestUtils.tempDirectory();
-        final Properties props = StreamsTestUtils.getStreamsConfig();
-        final InternalMockProcessorContext<String, String> context = new 
InternalMockProcessorContext<>(
-            dir,
-            Serdes.String(),
-            Serdes.String(),
-            new StreamsConfig(props)
-        );
-        store.init(context, store);
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void 
shouldReturnIdenticalResultsForNativeAndAdapterBuiltStores(final boolean 
cachingEnabled) {
+        // The existing (header-stripped) query types must return identical 
results on both build paths.
+        final ValueTimestampHeaders<String> value = 
ValueTimestampHeaders.make("v", 123L, headersWith("h", "x"));
 
+        final TimestampedKeyValueStoreWithHeaders<String, String> nativeStore 
= buildAndInitStore(StoreType.NATIVE, cachingEnabled);
+        final TimestampedKeyValueStoreWithHeaders<String, String> adapterStore 
= buildAndInitStore(StoreType.ADAPTER, cachingEnabled);
         try {
-            final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(new 
Bytes("test-key".getBytes()));
-            final PositionBound positionBound = PositionBound.unbounded();
-            final QueryConfig config = new QueryConfig(true); // Enable 
execution info
+            nativeStore.put("k", value);
+            adapterStore.put("k", value);
 
-            final StateStore wrapped = ((WrappedStateStore) store).wrapped();
-            final QueryResult<byte[]> result = wrapped.query(query, 
positionBound, config);
-
-            // Verify: Execution info includes both adapter and underlying 
store
-            assertFalse(result.getExecutionInfo().isEmpty(), "Expected 
execution info to be collected");
-
-            final String executionInfo = String.join("\n", 
result.getExecutionInfo());
-            assertTrue(
-                executionInfo.contains("Handled in"),
-                "Expected execution info to contain handling information"
-            );
-            // Should mention the adapter class
-            assertTrue(
-                
executionInfo.contains(TimestampedToHeadersStoreAdapter.class.getName()),
-                "Expected execution info to mention the adapter class"
-            );
+            assertEquals(
+                nativeStore.query(KeyQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false)).getResult(),
+                adapterStore.query(KeyQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false)).getResult(),
+                "KeyQuery results should be identical across native and 
adapter build paths");
+            assertEquals(
+                nativeStore.query(TimestampedKeyQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false)).getResult(),
+                adapterStore.query(TimestampedKeyQuery.withKey("k"), 
PositionBound.unbounded(), new QueryConfig(false)).getResult(),
+                "TimestampedKeyQuery results should be identical across native 
and adapter build paths");
         } finally {
-            store.close();
+            nativeStore.close();
+            adapterStore.close();
         }
     }
 }

Reply via email to