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 209a1b6e39a KAFKA-20328 : [1/N] headers-aware api test coverage 
(#22196)
209a1b6e39a is described below

commit 209a1b6e39a535453d302ca1306d24eded62bfda
Author: Murali Basani <[email protected]>
AuthorDate: Wed Jul 29 11:29:06 2026 +0200

    KAFKA-20328 : [1/N] headers-aware api test coverage (#22196)
    
    Ref : https://issues.apache.org/jira/browse/KAFKA-20328
    
    After audit, found a few gaps and also considered the work of 1271 kip
    This is first PR. Next PRs will be on Decorator/forwarder coverage —
    AbstractReadWriteDecorator and AbstractReadX related and other
    integration tests
    
    This PR :
    
    - new tests for  PlainToHeadersWindowStoreIteratorAdapterTest,
    RocksDBMigratingWindowStoreWithHeaders and
    TimestampedToHeadersStoreAdapter (considered deleted files in
    https://github.com/apache/kafka/pull/21830)
    - Update StoresTest with coverage for all 6 *WithHeaders factory methods
    on Stores
    - New test : DslStoreFormatTest cases for HEADERS enum
    - New contract tests for TimestampedKeyValueStoreWithHeaders,
    TimestampedWindowStoreWithHeaders and SessionStoreWithHeaders (with put,
    get, range, fetch, and backwardFetch, etc)
    
    Reviewers: Alieh Saeedi <[email protected]>, TengYao Chi
     <[email protected]>
---
 .../apache/kafka/streams/DslStoreFormatTest.java   |  57 +++
 .../state/SessionStoreWithHeadersContractTest.java | 222 ++++++++++++
 .../org/apache/kafka/streams/state/StoresTest.java | 128 +++++++
 ...tampedKeyValueStoreWithHeadersContractTest.java | 198 +++++++++++
 ...estampedWindowStoreWithHeadersContractTest.java | 225 ++++++++++++
 ...ainToHeadersWindowStoreIteratorAdapterTest.java | 114 ++++++
 ...RocksDBMigratingWindowStoreWithHeadersTest.java | 190 ++++++++++
 .../TimestampedToHeadersStoreAdapterTest.java      | 390 +++++++++++++++++++++
 8 files changed, 1524 insertions(+)

diff --git 
a/streams/src/test/java/org/apache/kafka/streams/DslStoreFormatTest.java 
b/streams/src/test/java/org/apache/kafka/streams/DslStoreFormatTest.java
new file mode 100644
index 00000000000..f3c8ca09c98
--- /dev/null
+++ b/streams/src/test/java/org/apache/kafka/streams/DslStoreFormatTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class DslStoreFormatTest {
+
+    @Test
+    public void shouldResolvePlain() {
+        assertEquals(DslStoreFormat.PLAIN, DslStoreFormat.of("PLAIN"));
+    }
+
+    @Test
+    public void shouldResolveTimestamped() {
+        assertEquals(DslStoreFormat.TIMESTAMPED, 
DslStoreFormat.of("TIMESTAMPED"));
+    }
+
+    @Test
+    public void shouldResolveHeaders() {
+        assertEquals(DslStoreFormat.HEADERS, DslStoreFormat.of("HEADERS"));
+    }
+
+    @Test
+    public void shouldResolveHeadersCaseInsensitively() {
+        assertEquals(DslStoreFormat.HEADERS, DslStoreFormat.of("headers"));
+        assertEquals(DslStoreFormat.HEADERS, DslStoreFormat.of("Headers"));
+        assertEquals(DslStoreFormat.HEADERS, DslStoreFormat.of("hEaDeRs"));
+    }
+
+    @Test
+    public void shouldExposeHeadersNameField() {
+        assertEquals("HEADERS", DslStoreFormat.HEADERS.name);
+    }
+
+    @Test
+    public void shouldThrowOnInvalidInput() {
+        assertThrows(IllegalArgumentException.class, () -> 
DslStoreFormat.of("not-a-format"));
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/SessionStoreWithHeadersContractTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/SessionStoreWithHeadersContractTest.java
new file mode 100644
index 00000000000..309fabc1c9c
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/SessionStoreWithHeadersContractTest.java
@@ -0,0 +1,222 @@
+/*
+ * 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.state;
+
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.streams.kstream.internals.SessionWindow;
+import org.apache.kafka.test.InternalMockProcessorContext;
+import org.apache.kafka.test.StreamsTestUtils;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Contract tests for {@link SessionStoreWithHeaders}.
+ */
+public class SessionStoreWithHeadersContractTest {
+
+    private static final long RETENTION = 10_000L;
+
+    private SessionStoreWithHeaders<String, String> store;
+    private InternalMockProcessorContext<String, String> context;
+
+    @BeforeEach
+    public void setUp() {
+        final File dir = TestUtils.tempDirectory();
+        final Properties props = StreamsTestUtils.getStreamsConfig();
+        context = new InternalMockProcessorContext<>(
+            dir,
+            Serdes.String(),
+            Serdes.String(),
+            new StreamsConfig(props)
+        );
+        store = Stores.sessionStoreWithHeadersBuilder(
+            Stores.inMemorySessionStore("contract-session-store", 
Duration.ofMillis(RETENTION)),
+            Serdes.String(),
+            Serdes.String()
+        ).withLoggingDisabled().withCachingDisabled().build();
+        store.init(context, store);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (store != null) {
+            store.close();
+        }
+    }
+
+    @Test
+    public void shouldRoundTripAggregationAndHeadersViaPutAndFetchSession() {
+        final Headers headers = headersWith("schema-id", "42");
+        final Windowed<String> key = windowed("k", 100L, 200L);
+
+        store.put(key, AggregationWithHeaders.make("agg", headers));
+
+        final AggregationWithHeaders<String> result = store.fetchSession("k", 
100L, 200L);
+        assertEquals("agg", result.aggregation());
+        assertEquals(headers, result.headers());
+    }
+
+    @Test
+    public void shouldReturnNullForMissingSession() {
+        assertNull(store.fetchSession("missing", 0L, 10L));
+    }
+
+    @Test
+    public void shouldTreatNullAggregationAsTombstone() {
+        final Headers headers = headersWith("h", "v");
+        final Windowed<String> key = windowed("k", 100L, 200L);
+
+        store.put(key, AggregationWithHeaders.make("agg", headers));
+        assertEquals("agg", store.fetchSession("k", 100L, 200L).aggregation());
+
+        store.put(key, null);
+        assertNull(store.fetchSession("k", 100L, 200L));
+    }
+
+    @Test
+    public void shouldRemoveSessionByWindowedKey() {
+        final Headers headers = headersWith("h", "v");
+        final Windowed<String> key = windowed("k", 100L, 200L);
+
+        store.put(key, AggregationWithHeaders.make("agg", headers));
+        store.remove(key);
+
+        assertNull(store.fetchSession("k", 100L, 200L));
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossFetchByKey() {
+        final Headers h1 = headersWith("id", "1");
+        final Headers h2 = headersWith("id", "2");
+        final Headers h3 = headersWith("id", "3");
+
+        store.put(windowed("k", 100L, 150L), AggregationWithHeaders.make("a1", 
h1));
+        store.put(windowed("k", 200L, 250L), AggregationWithHeaders.make("a2", 
h2));
+        store.put(windowed("k", 300L, 350L), AggregationWithHeaders.make("a3", 
h3));
+
+        final List<KeyValue<Long, Headers>> collected = new ArrayList<>();
+        try (KeyValueIterator<Windowed<String>, 
AggregationWithHeaders<String>> it = store.fetch("k")) {
+            while (it.hasNext()) {
+                final KeyValue<Windowed<String>, 
AggregationWithHeaders<String>> next = it.next();
+                collected.add(KeyValue.pair(next.key.window().start(), 
next.value.headers()));
+            }
+        }
+
+        assertEquals(3, collected.size());
+        assertEquals(100L, collected.get(0).key.longValue());
+        assertEquals(h1, collected.get(0).value);
+        assertEquals(200L, collected.get(1).key.longValue());
+        assertEquals(h2, collected.get(1).value);
+        assertEquals(300L, collected.get(2).key.longValue());
+        assertEquals(h3, collected.get(2).value);
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossFindSessions() {
+        final Headers h1 = headersWith("id", "early");
+        final Headers h2 = headersWith("id", "late");
+
+        store.put(windowed("k", 100L, 150L), AggregationWithHeaders.make("a1", 
h1));
+        store.put(windowed("k", 200L, 250L), AggregationWithHeaders.make("a2", 
h2));
+
+        final List<KeyValue<Long, Headers>> collected = new ArrayList<>();
+        try (KeyValueIterator<Windowed<String>, 
AggregationWithHeaders<String>> it =
+                 store.findSessions("k", 100L, 250L)) {
+            while (it.hasNext()) {
+                final KeyValue<Windowed<String>, 
AggregationWithHeaders<String>> next = it.next();
+                collected.add(KeyValue.pair(next.key.window().start(), 
next.value.headers()));
+            }
+        }
+
+        assertEquals(2, collected.size());
+        assertEquals(100L, collected.get(0).key.longValue());
+        assertEquals(h1, collected.get(0).value);
+        assertEquals(200L, collected.get(1).key.longValue());
+        assertEquals(h2, collected.get(1).value);
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossBackwardFindSessions() {
+        final Headers h1 = headersWith("id", "early");
+        final Headers h2 = headersWith("id", "late");
+
+        store.put(windowed("k", 100L, 150L), AggregationWithHeaders.make("a1", 
h1));
+        store.put(windowed("k", 200L, 250L), AggregationWithHeaders.make("a2", 
h2));
+
+        final List<KeyValue<Long, Headers>> collected = new ArrayList<>();
+        try (KeyValueIterator<Windowed<String>, 
AggregationWithHeaders<String>> it =
+                 store.backwardFindSessions("k", 100L, 250L)) {
+            while (it.hasNext()) {
+                final KeyValue<Windowed<String>, 
AggregationWithHeaders<String>> next = it.next();
+                collected.add(KeyValue.pair(next.key.window().start(), 
next.value.headers()));
+            }
+        }
+
+        assertEquals(2, collected.size());
+        assertEquals(200L, collected.get(0).key.longValue());
+        assertEquals(h2, collected.get(0).value);
+        assertEquals(100L, collected.get(1).key.longValue());
+        assertEquals(h1, collected.get(1).value);
+    }
+
+    @Test
+    public void shouldReturnEmptyIteratorForMissingKey() {
+        try (KeyValueIterator<Windowed<String>, 
AggregationWithHeaders<String>> it = store.fetch("missing")) {
+            assertFalse(it.hasNext());
+        }
+    }
+
+    @Test
+    public void shouldPreserveEmptyHeaders() {
+        final Windowed<String> key = windowed("k", 100L, 200L);
+        store.put(key, AggregationWithHeaders.make("agg", new 
RecordHeaders()));
+
+        final AggregationWithHeaders<String> result = store.fetchSession("k", 
100L, 200L);
+        assertEquals("agg", result.aggregation());
+        assertEquals(new RecordHeaders(), result.headers());
+        assertEquals(0, result.headers().toArray().length);
+    }
+
+    private static Windowed<String> windowed(final String key, final long 
start, final long end) {
+        return new Windowed<>(key, new SessionWindow(start, end));
+    }
+
+    private static Headers headersWith(final String key, final String value) {
+        final Headers headers = new RecordHeaders();
+        headers.add(new RecordHeader(key, value.getBytes()));
+        return headers;
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/StoresTest.java 
b/streams/src/test/java/org/apache/kafka/streams/state/StoresTest.java
index d5c588b59ee..0c5c7e41a27 100644
--- a/streams/src/test/java/org/apache/kafka/streams/state/StoresTest.java
+++ b/streams/src/test/java/org/apache/kafka/streams/state/StoresTest.java
@@ -324,4 +324,132 @@ public class StoresTest {
         ).build();
         assertThat(store, not(nullValue()));
     }
+
+    @Test
+    public void 
shouldThrowIfPersistentTimestampedKeyValueStoreWithHeadersNameIsNull() {
+        final Exception e = assertThrows(NullPointerException.class,
+            () -> Stores.persistentTimestampedKeyValueStoreWithHeaders(null));
+        assertEquals("name cannot be null", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldThrowIfPersistentTimestampedWindowStoreWithHeadersNameIsNull() {
+        final Exception e = assertThrows(NullPointerException.class,
+            () -> Stores.persistentTimestampedWindowStoreWithHeaders(null, 
ZERO, ZERO, false));
+        assertEquals("name cannot be null", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldThrowIfPersistentTimestampedWindowStoreWithHeadersRetentionPeriodIsNegative()
 {
+        final Exception e = assertThrows(IllegalArgumentException.class,
+            () -> 
Stores.persistentTimestampedWindowStoreWithHeaders("anyName", ofMillis(-1L), 
ZERO, false));
+        assertEquals("retentionPeriod cannot be negative", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldThrowIfPersistentTimestampedWindowStoreWithHeadersWindowSizeIsNegative() {
+        final Exception e = assertThrows(IllegalArgumentException.class,
+            () -> 
Stores.persistentTimestampedWindowStoreWithHeaders("anyName", ofMillis(0L), 
ofMillis(-1L), false));
+        assertEquals("windowSize cannot be negative", e.getMessage());
+    }
+
+    @Test
+    public void shouldThrowIfPersistentSessionStoreWithHeadersNameIsNull() {
+        final Exception e = assertThrows(NullPointerException.class,
+            () -> Stores.persistentSessionStoreWithHeaders(null, ofMillis(0)));
+        assertEquals("name cannot be null", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldThrowIfPersistentSessionStoreWithHeadersRetentionPeriodIsNegative() {
+        final Exception e = assertThrows(IllegalArgumentException.class,
+            () -> Stores.persistentSessionStoreWithHeaders("anyName", 
ofMillis(-1)));
+        assertEquals("retentionPeriod cannot be negative", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldThrowIfSupplierIsNullForTimestampedKeyValueStoreWithHeadersBuilder() {
+        final Exception e = assertThrows(NullPointerException.class,
+            () -> Stores.timestampedKeyValueStoreWithHeadersBuilder(null, 
Serdes.String(), Serdes.String()));
+        assertEquals("supplier cannot be null", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldThrowIfSupplierIsNullForTimestampedWindowStoreWithHeadersBuilder() {
+        final Exception e = assertThrows(NullPointerException.class,
+            () -> Stores.timestampedWindowStoreWithHeadersBuilder(null, 
Serdes.String(), Serdes.String()));
+        assertEquals("supplier cannot be null", e.getMessage());
+    }
+
+    @Test
+    public void shouldThrowIfSupplierIsNullForSessionStoreWithHeadersBuilder() 
{
+        final Exception e = assertThrows(NullPointerException.class,
+            () -> Stores.sessionStoreWithHeadersBuilder(null, Serdes.String(), 
Serdes.String()));
+        assertEquals("supplier cannot be null", e.getMessage());
+    }
+
+    @Test
+    public void 
shouldCreatePersistentTimestampedKeyValueStoreWithHeadersSupplier() {
+        final KeyValueBytesStoreSupplier supplier = 
Stores.persistentTimestampedKeyValueStoreWithHeaders("store");
+        assertThat(supplier.name(), equalTo("store"));
+        assertThat(supplier.metricsScope(), equalTo("rocksdb"));
+        assertThat(supplier.get(), not(nullValue()));
+        assertThat(supplier.get().persistent(), equalTo(true));
+    }
+
+    @Test
+    public void 
shouldCreatePersistentTimestampedWindowStoreWithHeadersSupplier() {
+        final WindowBytesStoreSupplier supplier =
+            Stores.persistentTimestampedWindowStoreWithHeaders("store", 
ofMillis(10L), ofMillis(5L), false);
+        assertThat(supplier.name(), equalTo("store"));
+        assertThat(supplier.windowSize(), equalTo(5L));
+        assertThat(supplier.retentionPeriod(), equalTo(10L));
+        assertThat(supplier.retainDuplicates(), equalTo(false));
+        assertThat(supplier.get(), not(nullValue()));
+        assertThat(supplier.get().persistent(), equalTo(true));
+    }
+
+    @Test
+    public void shouldCreatePersistentSessionStoreWithHeadersSupplier() {
+        final SessionBytesStoreSupplier supplier =
+            Stores.persistentSessionStoreWithHeaders("store", ofMillis(100L));
+        assertThat(supplier.name(), equalTo("store"));
+        assertThat(supplier.metricsScope(), equalTo("rocksdb-session"));
+        assertThat(supplier.retentionPeriod(), equalTo(100L));
+        assertThat(supplier.get(), not(nullValue()));
+        assertThat(supplier.get().persistent(), equalTo(true));
+    }
+
+    @Test
+    public void shouldBuildTimestampedKeyValueStoreWithHeaders() {
+        final TimestampedKeyValueStoreWithHeaders<String, String> store =
+            Stores.timestampedKeyValueStoreWithHeadersBuilder(
+                Stores.inMemoryKeyValueStore("name"),
+                Serdes.String(),
+                Serdes.String()
+            ).withLoggingDisabled().build();
+        assertThat(store, not(nullValue()));
+    }
+
+    @Test
+    public void shouldBuildTimestampedWindowStoreWithHeaders() {
+        final TimestampedWindowStoreWithHeaders<String, String> store =
+            Stores.timestampedWindowStoreWithHeadersBuilder(
+                Stores.inMemoryWindowStore("store", ofMillis(10L), 
ofMillis(5L), false),
+                Serdes.String(),
+                Serdes.String()
+            ).withLoggingDisabled().build();
+        assertThat(store, not(nullValue()));
+    }
+
+    @Test
+    public void shouldBuildSessionStoreWithHeaders() {
+        final SessionStoreWithHeaders<String, String> store =
+            Stores.sessionStoreWithHeadersBuilder(
+                Stores.inMemorySessionStore("name", ofMillis(100L)),
+                Serdes.String(),
+                Serdes.String()
+            ).withLoggingDisabled().build();
+        assertThat(store, not(nullValue()));
+    }
 }
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/TimestampedKeyValueStoreWithHeadersContractTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/TimestampedKeyValueStoreWithHeadersContractTest.java
new file mode 100644
index 00000000000..7f2ead2b08f
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/TimestampedKeyValueStoreWithHeadersContractTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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.state;
+
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.test.InternalMockProcessorContext;
+import org.apache.kafka.test.StreamsTestUtils;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Contract tests for {@link TimestampedKeyValueStoreWithHeaders}.
+ */
+public class TimestampedKeyValueStoreWithHeadersContractTest {
+
+    private TimestampedKeyValueStoreWithHeaders<String, String> store;
+    private InternalMockProcessorContext<String, String> context;
+
+    @BeforeEach
+    public void setUp() {
+        final File dir = TestUtils.tempDirectory();
+        final Properties props = StreamsTestUtils.getStreamsConfig();
+        context = new InternalMockProcessorContext<>(
+            dir,
+            Serdes.String(),
+            Serdes.String(),
+            new StreamsConfig(props)
+        );
+        store = Stores.timestampedKeyValueStoreWithHeadersBuilder(
+            Stores.inMemoryKeyValueStore("contract-store"),
+            Serdes.String(),
+            Serdes.String()
+        ).withLoggingDisabled().withCachingDisabled().build();
+        store.init(context, store);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (store != null) {
+            store.close();
+        }
+    }
+
+    @Test
+    public void shouldRoundTripValueTimestampAndHeadersViaPutAndGet() {
+        final Headers headers = headersWith("schema-id", "42");
+        store.put("k1", ValueTimestampHeaders.make("v1", 1000L, headers));
+
+        final ValueTimestampHeaders<String> result = store.get("k1");
+        assertEquals("v1", result.value());
+        assertEquals(1000L, result.timestamp());
+        assertEquals(headers, result.headers());
+    }
+
+    @Test
+    public void shouldReturnNullForMissingKey() {
+        assertNull(store.get("missing"));
+    }
+
+    @Test
+    public void shouldTreatNullValueAsTombstone() {
+        final Headers headers = headersWith("h", "v");
+        store.put("k", ValueTimestampHeaders.make("value", 10L, headers));
+        assertEquals("value", store.get("k").value());
+
+        store.put("k", null);
+        assertNull(store.get("k"));
+    }
+
+    @Test
+    public void shouldDeleteKeyAndReturnPriorValue() {
+        final Headers headers = headersWith("h", "v");
+        store.put("k", ValueTimestampHeaders.make("value", 10L, headers));
+
+        final ValueTimestampHeaders<String> deleted = store.delete("k");
+        assertEquals("value", deleted.value());
+        assertEquals(10L, deleted.timestamp());
+        assertEquals(headers, deleted.headers());
+        assertNull(store.get("k"));
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossRange() {
+        final Headers h1 = headersWith("id", "1");
+        final Headers h2 = headersWith("id", "2");
+        final Headers h3 = headersWith("id", "3");
+
+        store.put("a", ValueTimestampHeaders.make("va", 100L, h1));
+        store.put("b", ValueTimestampHeaders.make("vb", 200L, h2));
+        store.put("c", ValueTimestampHeaders.make("vc", 300L, h3));
+
+        final List<KeyValue<String, ValueTimestampHeaders<String>>> collected 
= new ArrayList<>();
+        try (KeyValueIterator<String, ValueTimestampHeaders<String>> it = 
store.range("a", "c")) {
+            while (it.hasNext()) {
+                collected.add(it.next());
+            }
+        }
+
+        assertEquals(3, collected.size());
+        assertEquals("a", collected.get(0).key);
+        assertEquals(h1, collected.get(0).value.headers());
+        assertEquals("b", collected.get(1).key);
+        assertEquals(h2, collected.get(1).value.headers());
+        assertEquals("c", collected.get(2).key);
+        assertEquals(h3, collected.get(2).value.headers());
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossReverseRange() {
+        final Headers h1 = headersWith("id", "1");
+        final Headers h2 = headersWith("id", "2");
+
+        store.put("a", ValueTimestampHeaders.make("va", 100L, h1));
+        store.put("b", ValueTimestampHeaders.make("vb", 200L, h2));
+
+        final List<KeyValue<String, ValueTimestampHeaders<String>>> collected 
= new ArrayList<>();
+        try (KeyValueIterator<String, ValueTimestampHeaders<String>> it = 
store.reverseRange("a", "b")) {
+            while (it.hasNext()) {
+                collected.add(it.next());
+            }
+        }
+
+        assertEquals(2, collected.size());
+        assertEquals("b", collected.get(0).key);
+        assertEquals(h2, collected.get(0).value.headers());
+        assertEquals("a", collected.get(1).key);
+        assertEquals(h1, collected.get(1).value.headers());
+    }
+
+    @Test
+    public void shouldReturnEmptyIteratorWhenStoreIsEmpty() {
+        try (KeyValueIterator<String, ValueTimestampHeaders<String>> it = 
store.all()) {
+            assertFalse(it.hasNext());
+        }
+    }
+
+    @Test
+    public void shouldPutIfAbsentAndNotOverwriteExisting() {
+        final Headers first = headersWith("h", "first");
+        final Headers second = headersWith("h", "second");
+
+        assertNull(store.putIfAbsent("k", ValueTimestampHeaders.make("v1", 1L, 
first)));
+
+        final ValueTimestampHeaders<String> previous =
+            store.putIfAbsent("k", ValueTimestampHeaders.make("v2", 2L, 
second));
+        assertEquals("v1", previous.value());
+        assertEquals(first, previous.headers());
+        assertEquals("v1", store.get("k").value());
+        assertEquals(first, store.get("k").headers());
+    }
+
+    @Test
+    public void shouldPreserveEmptyHeaders() {
+        store.put("k", ValueTimestampHeaders.make("v", 10L, new 
RecordHeaders()));
+
+        final ValueTimestampHeaders<String> result = store.get("k");
+        assertEquals("v", result.value());
+        assertEquals(new RecordHeaders(), result.headers());
+        assertEquals(0, result.headers().toArray().length);
+    }
+
+    private static Headers headersWith(final String key, final String value) {
+        final Headers headers = new RecordHeaders();
+        headers.add(new RecordHeader(key, value.getBytes()));
+        return headers;
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/TimestampedWindowStoreWithHeadersContractTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/TimestampedWindowStoreWithHeadersContractTest.java
new file mode 100644
index 00000000000..ed50ef52648
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/TimestampedWindowStoreWithHeadersContractTest.java
@@ -0,0 +1,225 @@
+/*
+ * 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.state;
+
+import org.apache.kafka.common.header.Headers;
+import org.apache.kafka.common.header.internals.RecordHeader;
+import org.apache.kafka.common.header.internals.RecordHeaders;
+import org.apache.kafka.common.serialization.Serdes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.kstream.Windowed;
+import org.apache.kafka.test.InternalMockProcessorContext;
+import org.apache.kafka.test.StreamsTestUtils;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Properties;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Contract tests for {@link TimestampedWindowStoreWithHeaders}.
+ */
+public class TimestampedWindowStoreWithHeadersContractTest {
+
+    private static final long WINDOW_SIZE = 10L;
+    private static final long RETENTION = 1000L;
+
+    private TimestampedWindowStoreWithHeaders<String, String> store;
+    private InternalMockProcessorContext<String, String> context;
+
+    @BeforeEach
+    public void setUp() {
+        final File dir = TestUtils.tempDirectory();
+        final Properties props = StreamsTestUtils.getStreamsConfig();
+        context = new InternalMockProcessorContext<>(
+            dir,
+            Serdes.String(),
+            Serdes.String(),
+            new StreamsConfig(props)
+        );
+        store = Stores.timestampedWindowStoreWithHeadersBuilder(
+            Stores.inMemoryWindowStore(
+                "contract-window-store",
+                Duration.ofMillis(RETENTION),
+                Duration.ofMillis(WINDOW_SIZE),
+                false
+            ),
+            Serdes.String(),
+            Serdes.String()
+        ).withLoggingDisabled().withCachingDisabled().build();
+        store.init(context, store);
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (store != null) {
+            store.close();
+        }
+    }
+
+    @Test
+    public void shouldRoundTripValueTimestampAndHeadersViaPutAndFetch() {
+        final Headers headers = headersWith("schema-id", "42");
+        store.put("k", ValueTimestampHeaders.make("v", 100L, headers), 100L);
+
+        try (WindowStoreIterator<ValueTimestampHeaders<String>> it = 
store.fetch("k", 100L, 100L)) {
+            assertTrue(it.hasNext());
+            final KeyValue<Long, ValueTimestampHeaders<String>> next = 
it.next();
+            assertEquals(100L, next.key.longValue());
+            assertEquals("v", next.value.value());
+            assertEquals(100L, next.value.timestamp());
+            assertEquals(headers, next.value.headers());
+            assertFalse(it.hasNext());
+        }
+    }
+
+    @Test
+    public void shouldReturnEmptyIteratorForMissingKey() {
+        try (WindowStoreIterator<ValueTimestampHeaders<String>> it = 
store.fetch("missing", 0L, 1000L)) {
+            assertFalse(it.hasNext());
+        }
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossMultipleWindows() {
+        final Headers h1 = headersWith("id", "w1");
+        final Headers h2 = headersWith("id", "w2");
+        final Headers h3 = headersWith("id", "w3");
+
+        store.put("k", ValueTimestampHeaders.make("v1", 100L, h1), 100L);
+        store.put("k", ValueTimestampHeaders.make("v2", 200L, h2), 200L);
+        store.put("k", ValueTimestampHeaders.make("v3", 300L, h3), 300L);
+
+        final List<KeyValue<Long, Headers>> collected = new ArrayList<>();
+        try (WindowStoreIterator<ValueTimestampHeaders<String>> it = 
store.fetch("k", 100L, 300L)) {
+            while (it.hasNext()) {
+                final KeyValue<Long, ValueTimestampHeaders<String>> next = 
it.next();
+                collected.add(KeyValue.pair(next.key, next.value.headers()));
+            }
+        }
+
+        assertEquals(3, collected.size());
+        assertEquals(100L, collected.get(0).key.longValue());
+        assertEquals(h1, collected.get(0).value);
+        assertEquals(200L, collected.get(1).key.longValue());
+        assertEquals(h2, collected.get(1).value);
+        assertEquals(300L, collected.get(2).key.longValue());
+        assertEquals(h3, collected.get(2).value);
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossBackwardFetch() {
+        final Headers h1 = headersWith("id", "early");
+        final Headers h2 = headersWith("id", "late");
+
+        store.put("k", ValueTimestampHeaders.make("v1", 100L, h1), 100L);
+        store.put("k", ValueTimestampHeaders.make("v2", 200L, h2), 200L);
+
+        final List<KeyValue<Long, Headers>> collected = new ArrayList<>();
+        try (WindowStoreIterator<ValueTimestampHeaders<String>> it = 
store.backwardFetch("k", 100L, 200L)) {
+            while (it.hasNext()) {
+                final KeyValue<Long, ValueTimestampHeaders<String>> next = 
it.next();
+                collected.add(KeyValue.pair(next.key, next.value.headers()));
+            }
+        }
+
+        assertEquals(2, collected.size());
+        assertEquals(200L, collected.get(0).key.longValue());
+        assertEquals(h2, collected.get(0).value);
+        assertEquals(100L, collected.get(1).key.longValue());
+        assertEquals(h1, collected.get(1).value);
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossFetchAll() {
+        final Headers h1 = headersWith("id", "a");
+        final Headers h2 = headersWith("id", "b");
+
+        store.put("a", ValueTimestampHeaders.make("va", 100L, h1), 100L);
+        store.put("b", ValueTimestampHeaders.make("vb", 100L, h2), 100L);
+
+        int count = 0;
+        try (KeyValueIterator<Windowed<String>, ValueTimestampHeaders<String>> 
it =
+                 store.fetchAll(100L, 100L)) {
+            while (it.hasNext()) {
+                final KeyValue<Windowed<String>, 
ValueTimestampHeaders<String>> next = it.next();
+                if ("a".equals(next.key.key())) {
+                    assertEquals(h1, next.value.headers());
+                } else if ("b".equals(next.key.key())) {
+                    assertEquals(h2, next.value.headers());
+                }
+                count++;
+            }
+        }
+        assertEquals(2, count);
+    }
+
+    @Test
+    public void shouldPreserveHeadersAcrossBackwardFetchAll() {
+        final Headers h1 = headersWith("id", "early");
+        final Headers h2 = headersWith("id", "late");
+
+        store.put("k", ValueTimestampHeaders.make("v1", 100L, h1), 100L);
+        store.put("k", ValueTimestampHeaders.make("v2", 200L, h2), 200L);
+
+        final List<Long> timestamps = new ArrayList<>();
+        try (KeyValueIterator<Windowed<String>, ValueTimestampHeaders<String>> 
it =
+                 store.backwardFetchAll(100L, 200L)) {
+            while (it.hasNext()) {
+                final KeyValue<Windowed<String>, 
ValueTimestampHeaders<String>> next = it.next();
+                timestamps.add(next.key.window().start());
+                if (next.key.window().start() == 100L) {
+                    assertEquals(h1, next.value.headers());
+                } else {
+                    assertEquals(h2, next.value.headers());
+                }
+            }
+        }
+        assertEquals(2, timestamps.size());
+        assertTrue(timestamps.get(0) >= timestamps.get(1),
+            "backwardFetchAll should return newer windows first");
+    }
+
+    @Test
+    public void shouldPreserveEmptyHeaders() {
+        store.put("k", ValueTimestampHeaders.make("v", 100L, new 
RecordHeaders()), 100L);
+
+        try (WindowStoreIterator<ValueTimestampHeaders<String>> it = 
store.fetch("k", 100L, 100L)) {
+            assertTrue(it.hasNext());
+            final ValueTimestampHeaders<String> result = it.next().value;
+            assertEquals("v", result.value());
+            assertEquals(new RecordHeaders(), result.headers());
+        }
+    }
+
+    private static Headers headersWith(final String key, final String value) {
+        final Headers headers = new RecordHeaders();
+        headers.add(new RecordHeader(key, value.getBytes()));
+        return headers;
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/PlainToHeadersWindowStoreIteratorAdapterTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/PlainToHeadersWindowStoreIteratorAdapterTest.java
new file mode 100644
index 00000000000..fa52269c8d3
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/PlainToHeadersWindowStoreIteratorAdapterTest.java
@@ -0,0 +1,114 @@
+/*
+ * 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.state.internals;
+
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.WindowStoreIterator;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import static 
org.apache.kafka.streams.state.HeadersBytesStore.convertFromPlainToHeaderFormat;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.STRICT_STUBS)
+public class PlainToHeadersWindowStoreIteratorAdapterTest {
+
+    private static final byte[] PLAIN_VALUE = "value".getBytes();
+    private static final byte[] VALUE_WITH_EMPTY_HEADERS_AND_TS =
+        convertFromPlainToHeaderFormat(PLAIN_VALUE);
+
+    @Mock
+    private WindowStoreIterator<byte[]> innerIterator;
+
+    @Test
+    public void shouldImplementWindowStoreIteratorInterface() {
+        final PlainToHeadersWindowStoreIteratorAdapter adapter =
+            new PlainToHeadersWindowStoreIteratorAdapter(innerIterator);
+
+        assertInstanceOf(WindowStoreIterator.class, adapter);
+        assertInstanceOf(KeyValueIterator.class, adapter);
+        assertInstanceOf(PlainToHeadersIteratorAdapter.class, adapter);
+    }
+
+    @Test
+    public void shouldPrependEmptyHeadersAndSentinelTimestampOnNext() {
+        when(innerIterator.hasNext()).thenReturn(true);
+        when(innerIterator.next()).thenReturn(KeyValue.pair(42L, PLAIN_VALUE));
+
+        final PlainToHeadersWindowStoreIteratorAdapter adapter =
+            new PlainToHeadersWindowStoreIteratorAdapter(innerIterator);
+
+        assertTrue(adapter.hasNext());
+        final KeyValue<Long, byte[]> result = adapter.next();
+        assertEquals(42L, result.key.longValue());
+        assertArrayEquals(VALUE_WITH_EMPTY_HEADERS_AND_TS, result.value);
+    }
+
+    @Test
+    public void shouldReturnNullValueWhenInnerValueIsNull() {
+        when(innerIterator.next()).thenReturn(KeyValue.pair(42L, null));
+
+        final PlainToHeadersWindowStoreIteratorAdapter adapter =
+            new PlainToHeadersWindowStoreIteratorAdapter(innerIterator);
+
+        final KeyValue<Long, byte[]> result = adapter.next();
+        assertEquals(42L, result.key.longValue());
+        assertNull(result.value);
+    }
+
+    @Test
+    public void shouldReturnNullWhenInnerKeyValueIsNull() {
+        when(innerIterator.next()).thenReturn(null);
+
+        final PlainToHeadersWindowStoreIteratorAdapter adapter =
+            new PlainToHeadersWindowStoreIteratorAdapter(innerIterator);
+
+        assertNull(adapter.next());
+    }
+
+    @Test
+    public void shouldDelegatePeekNextKey() {
+        when(innerIterator.peekNextKey()).thenReturn(100L);
+
+        final PlainToHeadersWindowStoreIteratorAdapter adapter =
+            new PlainToHeadersWindowStoreIteratorAdapter(innerIterator);
+
+        assertEquals(100L, adapter.peekNextKey().longValue());
+    }
+
+    @Test
+    public void shouldDelegateClose() {
+        final PlainToHeadersWindowStoreIteratorAdapter adapter =
+            new PlainToHeadersWindowStoreIteratorAdapter(innerIterator);
+
+        adapter.close();
+        verify(innerIterator).close();
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBMigratingWindowStoreWithHeadersTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBMigratingWindowStoreWithHeadersTest.java
new file mode 100644
index 00000000000..353c43a5c29
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBMigratingWindowStoreWithHeadersTest.java
@@ -0,0 +1,190 @@
+/*
+ * 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.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.common.utils.LogCaptureAppender;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.internals.metrics.RocksDBMetricsRecorder;
+
+import org.junit.jupiter.api.Test;
+import org.rocksdb.ColumnFamilyDescriptor;
+import org.rocksdb.ColumnFamilyHandle;
+import org.rocksdb.ColumnFamilyOptions;
+import org.rocksdb.DBOptions;
+import org.rocksdb.RocksDB;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import static java.util.Arrays.asList;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for {@link RocksDBMigratingWindowStoreWithHeaders}.
+ */
+public class RocksDBMigratingWindowStoreWithHeadersTest extends 
RocksDBStoreTest {
+
+    private final byte[] windowStoreHeadersColumnFamilyName =
+        
RocksDBMigratingWindowStoreWithHeaders.WINDOW_STORE_HEADERS_VALUES_COLUMN_FAMILY_NAME;
+
+    RocksDBStore getRocksDBStore() {
+        return new RocksDBMigratingWindowStoreWithHeaders(
+            DB_NAME,
+            "rocksdb",
+            new RocksDBMetricsRecorder(METRICS_SCOPE, DB_NAME));
+    }
+
+    @Test
+    public void shouldOpenNewStoreInRegularMode() {
+        try (final LogCaptureAppender appender =
+                 
LogCaptureAppender.createAndRegister(RocksDBMigratingWindowStoreWithHeaders.class))
 {
+            rocksDBStore.init(context, rocksDBStore);
+
+            assertTrue(appender.getMessages().stream().anyMatch(m -> 
m.contains("in regular headers-aware mode")),
+                "Expected log message about regular headers-aware mode, got: " 
+ appender.getMessages());
+        }
+
+        try (final KeyValueIterator<Bytes, byte[]> iterator = 
rocksDBStore.all()) {
+            assertFalse(iterator.hasNext());
+        }
+    }
+
+    @Test
+    public void shouldOpenExistingHeadersAwareStoreInRegularMode() throws 
Exception {
+        final Bytes key = new Bytes("win-key".getBytes());
+        final byte[] value = new byte[] {0x00, 'v', 'a', 'l'};
+
+        rocksDBStore.init(context, rocksDBStore);
+        rocksDBStore.put(key, value);
+        rocksDBStore.close();
+
+        try (final LogCaptureAppender appender =
+                 
LogCaptureAppender.createAndRegister(RocksDBMigratingWindowStoreWithHeaders.class))
 {
+            rocksDBStore.init(context, rocksDBStore);
+
+            assertTrue(appender.getMessages().stream().anyMatch(m -> 
m.contains("in regular headers-aware mode")),
+                "Expected regular mode on re-open, got: " + 
appender.getMessages());
+        } finally {
+            rocksDBStore.close();
+        }
+
+        verifyValueLandedInHeadersColumnFamily(key, value.length);
+    }
+
+    @Test
+    public void shouldMigrateFromDefaultColumnFamilyWhenLegacyDataExists() 
throws Exception {
+        seedDefaultColumnFamilyWithLegacyData();
+
+        try (final LogCaptureAppender appender =
+                 
LogCaptureAppender.createAndRegister(RocksDBMigratingWindowStoreWithHeaders.class))
 {
+            rocksDBStore.init(context, rocksDBStore);
+
+            assertTrue(appender.getMessages().stream().anyMatch(m -> 
m.contains("in upgrade mode from plain value format")),
+                "Expected upgrade-mode log, got: " + appender.getMessages());
+        }
+
+        final byte[] legacyKey = "legacy".getBytes();
+        final byte[] migrated = rocksDBStore.get(new Bytes(legacyKey));
+        assertEquals(0x00, migrated[0], "Migrated value must begin with 
empty-headers prefix");
+        final byte[] payload = new byte[migrated.length - 1];
+        System.arraycopy(migrated, 1, payload, 0, payload.length);
+        assertArrayEquals("v1".getBytes(), payload);
+
+        assertNull(rocksDBStore.get(new Bytes("unknown".getBytes())));
+
+        rocksDBStore.close();
+    }
+
+    private void seedDefaultColumnFamilyWithLegacyData() {
+        final RocksDBStore plainStore = new RocksDBStore(DB_NAME, 
METRICS_SCOPE);
+        try {
+            plainStore.init(context, plainStore);
+            plainStore.put(new Bytes("legacy".getBytes()), "v1".getBytes());
+        } finally {
+            plainStore.close();
+        }
+    }
+
+    private void verifyValueLandedInHeadersColumnFamily(final Bytes key, final 
int expectedValueLength) throws Exception {
+        final DBOptions dbOptions = new DBOptions();
+        final ColumnFamilyOptions columnFamilyOptions = new 
ColumnFamilyOptions();
+
+        final List<ColumnFamilyDescriptor> columnFamilyDescriptors = asList(
+            new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY, 
columnFamilyOptions),
+            new ColumnFamilyDescriptor(windowStoreHeadersColumnFamilyName, 
columnFamilyOptions),
+            new 
ColumnFamilyDescriptor(RocksDBStore.OFFSETS_COLUMN_FAMILY_NAME, 
columnFamilyOptions));
+        final List<ColumnFamilyHandle> columnFamilies = new 
ArrayList<>(columnFamilyDescriptors.size());
+
+        RocksDB db = null;
+        ColumnFamilyHandle defaultCf = null;
+        ColumnFamilyHandle headersCf = null;
+        ColumnFamilyHandle offsetsCf = null;
+        try {
+            db = RocksDB.open(
+                dbOptions,
+                new File(new File(context.stateDir(), "rocksdb"), 
DB_NAME).getAbsolutePath(),
+                columnFamilyDescriptors,
+                columnFamilies);
+
+            defaultCf = columnFamilies.get(0);
+            headersCf = columnFamilies.get(1);
+            offsetsCf = columnFamilies.get(2);
+
+            assertNull(db.get(defaultCf, key.get()), "DEFAULT CF should not 
contain the key");
+            final byte[] inHeadersCf = db.get(headersCf, key.get());
+            assertEquals(expectedValueLength, inHeadersCf.length,
+                "Value should be stored in headers CF with the original 
length");
+        } finally {
+            if (offsetsCf != null) {
+                offsetsCf.close();
+            }
+            if (defaultCf != null) {
+                defaultCf.close();
+            }
+            if (headersCf != null) {
+                headersCf.close();
+            }
+            if (db != null) {
+                db.close();
+            }
+            dbOptions.close();
+            columnFamilyOptions.close();
+        }
+    }
+
+    @Test
+    public void shouldIterateOverBothColumnFamiliesInUpgradeMode() {
+        seedDefaultColumnFamilyWithLegacyData();
+        rocksDBStore.init(context, rocksDBStore);
+
+        try (final KeyValueIterator<Bytes, byte[]> it = rocksDBStore.all()) {
+            assertTrue(it.hasNext(), "Iterator should find the legacy key via 
on-the-fly conversion");
+            final KeyValue<Bytes, byte[]> kv = it.next();
+            assertArrayEquals("legacy".getBytes(), kv.key.get());
+            assertEquals(0x00, kv.value[0], "Legacy value must be returned 
with empty-headers prefix");
+        }
+
+        rocksDBStore.close();
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedToHeadersStoreAdapterTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedToHeadersStoreAdapterTest.java
new file mode 100644
index 00000000000..3b2787d4d6d
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedToHeadersStoreAdapterTest.java
@@ -0,0 +1,390 @@
+/*
+ * 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.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+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.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.TimestampedBytesStore;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.Arrays;
+
+import static 
org.apache.kafka.streams.state.HeadersBytesStore.convertToHeaderFormat;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+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.ArgumentMatchers.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.STRICT_STUBS)
+public class TimestampedToHeadersStoreAdapterTest {
+
+    @Mock
+    private KeyValueIterator<Bytes, byte[]> mockIterator;
+
+    @SuppressWarnings("unchecked")
+    private KeyValueStore<Bytes, byte[]> mockStore;
+
+    private TimestampedToHeadersStoreAdapter adapter;
+
+    @SuppressWarnings("unchecked")
+    @BeforeEach
+    public void setUp() {
+        mockStore = mock(KeyValueStore.class, 
withSettings().extraInterfaces(TimestampedBytesStore.class));
+        // lenient: this fixture stub is consumed by the adapter constructor 
for most tests, but the
+        // constructor-validation tests build their own store and never touch 
this one.
+        lenient().when(mockStore.persistent()).thenReturn(true);
+        adapter = new TimestampedToHeadersStoreAdapter(mockStore);
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void shouldThrowIfStoreIsNotPersistent() {
+        final KeyValueStore<Bytes, byte[]> nonPersistentStore =
+            mock(KeyValueStore.class, 
withSettings().extraInterfaces(TimestampedBytesStore.class));
+        when(nonPersistentStore.persistent()).thenReturn(false);
+
+        final IllegalArgumentException exception = assertThrows(
+            IllegalArgumentException.class,
+            () -> new TimestampedToHeadersStoreAdapter(nonPersistentStore)
+        );
+
+        assertTrue(exception.getMessage().contains("Provided store must be a 
persistent store"));
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void shouldThrowIfStoreIsNotTimestamped() {
+        final KeyValueStore<Bytes, byte[]> nonTimestampedStore = 
mock(KeyValueStore.class);
+        when(nonTimestampedStore.persistent()).thenReturn(true);
+
+        final IllegalArgumentException exception = assertThrows(
+            IllegalArgumentException.class,
+            () -> new TimestampedToHeadersStoreAdapter(nonTimestampedStore)
+        );
+
+        assertTrue(exception.getMessage().contains("Provided store must be a 
timestamped store"));
+    }
+
+    @Test
+    public void shouldPutRawTimestampedValueToStore() {
+        final Bytes key = new Bytes("key".getBytes());
+        final byte[] rawTimestampedValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 42, 'v', 'a', 'l'};
+        final byte[] valueWithHeaders = 
convertToHeaderFormat(rawTimestampedValue);
+
+        adapter.put(key, valueWithHeaders);
+
+        verify(mockStore).put(eq(key), eq(rawTimestampedValue));
+    }
+
+    @Test
+    public void shouldGetAndConvertToHeaderFormat() {
+        final Bytes key = new Bytes("key".getBytes());
+        final byte[] rawTimestampedValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 42, 'v', 'a', 'l'};
+        when(mockStore.get(key)).thenReturn(rawTimestampedValue);
+
+        final byte[] result = adapter.get(key);
+
+        assertArrayEquals(convertToHeaderFormat(rawTimestampedValue), result);
+    }
+
+    @Test
+    public void shouldReturnNullWhenStoreReturnsNull() {
+        final Bytes key = new Bytes("key".getBytes());
+        when(mockStore.get(key)).thenReturn(null);
+
+        assertNull(adapter.get(key));
+    }
+
+    @Test
+    public void shouldPutIfAbsentAndConvertResult() {
+        final Bytes key = new Bytes("key".getBytes());
+        final byte[] rawTimestampedValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 42, 'v', 'a', 'l'};
+        final byte[] valueWithHeaders = 
convertToHeaderFormat(rawTimestampedValue);
+        final byte[] oldRawValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 10, 'o', 'l', 'd'};
+        when(mockStore.putIfAbsent(eq(key), 
eq(rawTimestampedValue))).thenReturn(oldRawValue);
+
+        final byte[] result = adapter.putIfAbsent(key, valueWithHeaders);
+
+        assertArrayEquals(convertToHeaderFormat(oldRawValue), result);
+    }
+
+    @Test
+    public void shouldDeleteAndConvertResult() {
+        final Bytes key = new Bytes("key".getBytes());
+        final byte[] oldRawValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 10, 'o', 'l', 'd'};
+        when(mockStore.delete(key)).thenReturn(oldRawValue);
+
+        final byte[] result = adapter.delete(key);
+
+        assertArrayEquals(convertToHeaderFormat(oldRawValue), result);
+    }
+
+    @Test
+    public void shouldPutAllEntries() {
+        final Bytes key1 = new Bytes("key1".getBytes());
+        final Bytes key2 = new Bytes("key2".getBytes());
+        final byte[] rawValue1 =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 1, 'v', '1'};
+        final byte[] rawValue2 =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 2, 'v', '2'};
+        final byte[] value1 = convertToHeaderFormat(rawValue1);
+        final byte[] value2 = convertToHeaderFormat(rawValue2);
+
+        adapter.putAll(Arrays.asList(
+            KeyValue.pair(key1, value1),
+            KeyValue.pair(key2, value2)
+        ));
+
+        verify(mockStore).put(eq(key1), eq(rawValue1));
+        verify(mockStore).put(eq(key2), eq(rawValue2));
+    }
+
+    @Test
+    public void shouldWrapRangeIterator() {
+        final Bytes from = new Bytes("a".getBytes());
+        final Bytes to = new Bytes("z".getBytes());
+        when(mockStore.range(from, to)).thenReturn(mockIterator);
+
+        final KeyValueIterator<Bytes, byte[]> result = adapter.range(from, to);
+
+        assertNotNull(result);
+        assertInstanceOf(TimestampedToHeadersIteratorAdapter.class, result);
+    }
+
+    @Test
+    public void shouldWrapReverseRangeIterator() {
+        final Bytes from = new Bytes("a".getBytes());
+        final Bytes to = new Bytes("z".getBytes());
+        when(mockStore.reverseRange(from, to)).thenReturn(mockIterator);
+
+        final KeyValueIterator<Bytes, byte[]> result = 
adapter.reverseRange(from, to);
+
+        assertNotNull(result);
+        assertInstanceOf(TimestampedToHeadersIteratorAdapter.class, result);
+    }
+
+    @Test
+    public void shouldWrapAllIterator() {
+        when(mockStore.all()).thenReturn(mockIterator);
+
+        final KeyValueIterator<Bytes, byte[]> result = adapter.all();
+
+        assertNotNull(result);
+        assertInstanceOf(TimestampedToHeadersIteratorAdapter.class, result);
+    }
+
+    @Test
+    public void shouldWrapReverseAllIterator() {
+        when(mockStore.reverseAll()).thenReturn(mockIterator);
+
+        final KeyValueIterator<Bytes, byte[]> result = adapter.reverseAll();
+
+        assertNotNull(result);
+        assertInstanceOf(TimestampedToHeadersIteratorAdapter.class, result);
+    }
+
+    @Test
+    public void shouldWrapPrefixScanIterator() {
+        when(mockStore.prefixScan(any(), any())).thenReturn(mockIterator);
+
+        final KeyValueIterator<Bytes, byte[]> result =
+            adapter.prefixScan("prefix", (topic, data) -> data.getBytes());
+
+        assertNotNull(result);
+        assertInstanceOf(TimestampedToHeadersIteratorAdapter.class, result);
+    }
+
+    @Test
+    public void shouldHandleKeyQuery() {
+        final Bytes key = new Bytes("test-key".getBytes());
+        final byte[] rawTimestampedValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 42, 'v', 'a', 'l'};
+        final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(key);
+
+        final QueryResult<byte[]> mockResult = 
QueryResult.forResult(rawTimestampedValue);
+        when(mockStore.query(eq(query), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn(mockResult);
+
+        final QueryResult<byte[]> result =
+            adapter.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+
+        assertTrue(result.isSuccess());
+        assertArrayEquals(convertToHeaderFormat(rawTimestampedValue), 
result.getResult());
+    }
+
+    @Test
+    public void shouldHandleKeyQueryWithNullResult() {
+        final Bytes key = new Bytes("test-key".getBytes());
+        final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(key);
+
+        final QueryResult<byte[]> mockResult = QueryResult.forResult(null);
+        when(mockStore.query(eq(query), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn(mockResult);
+
+        final QueryResult<byte[]> result =
+            adapter.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+
+        assertTrue(result.isSuccess());
+        assertNull(result.getResult());
+    }
+
+    @Test
+    public void shouldHandleFailedKeyQuery() {
+        final Bytes key = new Bytes("test-key".getBytes());
+        final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(key);
+
+        final QueryResult<byte[]> mockResult = 
QueryResult.forUnknownQueryType(query, mockStore);
+        when(mockStore.query(eq(query), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn(mockResult);
+
+        final QueryResult<byte[]> result =
+            adapter.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+
+        assertFalse(result.isSuccess());
+    }
+
+    @Test
+    public void shouldHandleRangeQuery() {
+        final RangeQuery<Bytes, byte[]> query = RangeQuery.withRange(
+            new Bytes("a".getBytes()),
+            new Bytes("z".getBytes())
+        );
+
+        final QueryResult<KeyValueIterator<Bytes, byte[]>> mockResult =
+            QueryResult.forResult(mockIterator);
+        when(mockStore.query(eq(query), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn(mockResult);
+
+        final QueryResult<KeyValueIterator<Bytes, byte[]>> result = 
adapter.query(
+            query,
+            PositionBound.unbounded(),
+            new QueryConfig(false)
+        );
+
+        assertTrue(result.isSuccess());
+        assertNotNull(result.getResult());
+        assertInstanceOf(TimestampedToHeadersIteratorAdapter.class, 
result.getResult());
+    }
+
+    @Test
+    @SuppressWarnings("unchecked")
+    public void shouldDelegateOtherQueryTypesToStore() {
+        // Any query that is neither KeyQuery nor RangeQuery falls through to 
the
+        // else branch and is passed straight to the underlying store, 
unchanged.
+        final Query<String> query = mock(Query.class);
+        final QueryResult<String> mockResult = 
QueryResult.forResult("delegated");
+        when(mockStore.query(eq(query), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn(mockResult);
+
+        final QueryResult<String> result =
+            adapter.query(query, PositionBound.unbounded(), new 
QueryConfig(false));
+
+        assertSame(mockResult, result);
+        assertTrue(result.isSuccess());
+        assertEquals("delegated", result.getResult());
+    }
+
+    @Test
+    public void shouldCollectExecutionInfoForKeyQuery() {
+        final Bytes key = new Bytes("test-key".getBytes());
+        final byte[] rawTimestampedValue =
+            new byte[] {0, 0, 0, 0, 0, 0, 0, 42, 'v', 'a', 'l'};
+        final KeyQuery<Bytes, byte[]> query = KeyQuery.withKey(key);
+
+        final QueryResult<byte[]> mockResult = 
QueryResult.forResult(rawTimestampedValue);
+        when(mockStore.query(eq(query), any(PositionBound.class), 
any(QueryConfig.class)))
+            .thenReturn(mockResult);
+
+        final QueryResult<byte[]> result =
+            adapter.query(query, PositionBound.unbounded(), new 
QueryConfig(true));
+
+        assertTrue(result.isSuccess());
+        assertFalse(result.getExecutionInfo().isEmpty(),
+            "Expected execution info to be collected");
+        final String executionInfo = String.join("\n", 
result.getExecutionInfo());
+        assertTrue(executionInfo.contains("Handled in"));
+        
assertTrue(executionInfo.contains(TimestampedToHeadersStoreAdapter.class.getName()));
+    }
+
+    @Test
+    public void shouldDelegateName() {
+        when(mockStore.name()).thenReturn("test-store");
+
+        assertEquals("test-store", adapter.name());
+    }
+
+    @Test
+    public void shouldReturnTrueForPersistent() {
+        assertTrue(adapter.persistent());
+    }
+
+    @Test
+    public void shouldDelegateIsOpen() {
+        when(mockStore.isOpen()).thenReturn(true);
+
+        assertTrue(adapter.isOpen());
+    }
+
+    @Test
+    public void shouldDelegateApproximateNumEntries() {
+        when(mockStore.approximateNumEntries()).thenReturn(42L);
+
+        assertEquals(42L, adapter.approximateNumEntries());
+    }
+
+    @Test
+    public void shouldDelegateGetPosition() {
+        when(mockStore.getPosition()).thenReturn(null);
+
+        adapter.getPosition();
+
+        verify(mockStore).getPosition();
+    }
+}

Reply via email to