aliehsaeedii commented on code in PR #22770:
URL: https://github.com/apache/kafka/pull/22770#discussion_r3554968478


##########
streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java:
##########
@@ -683,6 +722,7 @@ public void close() {
                 final long duration = time.nanoseconds() - startNs;
                 sensor.record(duration);
                 iteratorDurationSensor.record(duration);
+                numOpenIterators.decrement();

Review Comment:
   The sibling `MeteredTimestampedKeyValueStore` has the same gauge bug this PR 
fixes here: its `MeteredTimestampedKeyValueStoreIterator` does 
`openIterators.add`/`remove` but never 
`numOpenIterators.increment()`/`decrement()`, so `num-open-iterators` 
under-counts for plain timestamped stores. Could you please open a jira ticket 
for that too? Maybe not related to your KIP, but just as a bug.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java:
##########
@@ -542,6 +546,40 @@ private <R> QueryResult<R> runTimestampedRangeQuery(
         return result;
     }
 
+    @SuppressWarnings("unchecked")
+    private <R> QueryResult<R> runTimestampedRangeWithHeadersQuery(
+        final Query<R> query,
+        final PositionBound positionBound,
+        final QueryConfig config
+    ) {
+        final QueryResult<R> result;
+        final TimestampedRangeWithHeadersQuery<K, V> typedQuery = 
(TimestampedRangeWithHeadersQuery<K, V>) query;
+
+        final RangeQuery<Bytes, byte[]> rawRangeQuery =
+            rawRangeQuery(typedQuery.lowerBound(), typedQuery.upperBound(), 
typedQuery.resultOrder());
+
+        final QueryResult<KeyValueIterator<Bytes, byte[]>> rawResult = 
wrapped().query(rawRangeQuery, positionBound, config);
+        if (rawResult.isSuccess()) {
+            final KeyValueIterator<Bytes, byte[]> iterator = 
rawResult.getResult();
+            final ReadOnlyRecordIterator<K, V> resultIterator =
+                new 
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator(
+                    iterator,
+                    getSensor,
+                    StoreQueryUtils.deserializeValue(serdes, wrapped())
+                );
+            final QueryResult<ReadOnlyRecordIterator<K, V>> typedQueryResult =
+                InternalQueryResultUtil.copyAndSubstituteDeserializedResult(
+                    rawResult,
+                    resultIterator
+                );
+            result = (QueryResult<R>) typedQueryResult;
+        } else {
+            // the generic type doesn't matter, since failed queries have no 
result set.
+            result = (QueryResult<R>) rawResult;

Review Comment:
   This failure branch (wrapped query returns a failure) has no test — every 
range-with-headers test asserts `isSuccess()`. Minor, but a failed-rawResult 
case would close it (the sibling handlers share the same untested else).



##########
streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java:
##########
@@ -615,4 +605,205 @@ public void 
shouldReturnIdenticalResultsForNativeAndAdapterBuiltStores(final boo
             adapterStore.close();
         }
     }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void shouldHandleRangeQuery(final boolean cachingEnabled) {

Review Comment:
   This asserts only success + an empty result from an empty store, so it can't 
catch wrong range data — it's really a 'handled, not UNKNOWN_QUERY_TYPE' smoke 
test. Consider putting a record and asserting it comes back.



##########
streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java:
##########
@@ -188,57 +197,186 @@ public void 
shouldServeCacheHitWhenCachingEnabledAndRecordNotYetFlushed() throws
     }
 
     @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()));
+    public void 
shouldFailWithUnknownQueryTypeForKeyQueryAgainstNonHeadersStore() throws 
Exception {
+        
assertUnknownQueryTypeAgainstNonHeadersStore(TimestampedKeyWithHeadersQuery.<Integer,
 String>withKey(1));
+    }
 
-        kafkaStreams = new KafkaStreams(builder.build(), props());
-        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+    @Test
+    public void shouldHandleTimestampedRangeWithHeadersQuery() throws 
Exception {
+        // Caching disabled: a range query reads the underlying store directly 
(it never consults the
+        // cache), so the writes must be store-served.
+        startStreamsWithHeadersStore();
 
-        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS, 
KeyValue.pair(1, "a0"));
+        // keys 1,2 (headers), key 3 (empty headers), key 4 written then 
tombstone
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
+            KeyValue.pair(1, "one"), KeyValue.pair(2, "two"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 1, new 
RecordHeaders(),
+            KeyValue.pair(3, "three"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 2, HEADERS,
+            KeyValue.pair(4, "four"), KeyValue.pair(4, null));
+
+        // Full scan, ascending: keys 1, 2, 3 (key 4 tombstone and omitted).
+        final List<ReadOnlyRecord<Integer, String>> ascending =
+            rangeQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds().withAscendingKeys());
+        assertEquals(List.of(1, 2, 3), keys(ascending));
+        assertEquals(List.of("one", "two", "three"), values(ascending));
+        // key/value/timestamp/headers carried on each element
+        assertEquals(HEADERS, ascending.get(0).headers());          // key 1
+        assertEquals(baseTimestamp, ascending.get(0).timestamp());
+        assertEquals(HEADERS, ascending.get(1).headers());          // key 2
+        assertEquals(baseTimestamp, ascending.get(1).timestamp());
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), ascending.get(2).headers());
+        assertEquals(baseTimestamp + 1, ascending.get(2).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
ascending.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
ascending.get(0).headers().remove("source"));
+
+        // Full scan, descending: keys reversed, payload still carried.
+        final List<ReadOnlyRecord<Integer, String>> descending =
+            rangeQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds().withDescendingKeys());
+        assertEquals(List.of(3, 2, 1), keys(descending));
+        assertEquals(List.of("three", "two", "one"), values(descending));
+        // key/value/timestamp/headers carried on each element
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), descending.get(0).headers());  // 
key 3
+        assertEquals(baseTimestamp + 1, descending.get(0).timestamp());
+        assertEquals(HEADERS, descending.get(1).headers());             // key 
2
+        assertEquals(baseTimestamp, descending.get(1).timestamp());
+        assertEquals(HEADERS, descending.get(2).headers());             // key 
1
+        assertEquals(baseTimestamp, descending.get(2).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
descending.get(2).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
descending.get(2).headers().remove("source"));
+
+        // Bounded ranges (inclusive on both ends); same per-element checks as 
the full scans.
+        // withRange(2, 3) -> keys 2, 3.
+        final List<ReadOnlyRecord<Integer, String>> range = 
rangeQuery(TimestampedRangeWithHeadersQuery.withRange(2, 3));

Review Comment:
   The bounded ranges (withRange/withLowerBound/withUpperBound) are only 
exercised in the default order. Add one descending bounded range so bounds + 
DESCENDING is covered end-to-end (only the full scan tests descending today).



##########
streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java:
##########
@@ -615,4 +605,205 @@ public void 
shouldReturnIdenticalResultsForNativeAndAdapterBuiltStores(final boo
             adapterStore.close();
         }
     }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void shouldHandleRangeQuery(final boolean cachingEnabled) {
+        // KIP-1356: the native header store now serves RangeQuery 
(header-stripped results), matching
+        // the adapter build path (caching on or off).
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, cachingEnabled);
+        try {
+            final QueryResult<KeyValueIterator<String, String>> result =
+                store.query(RangeQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+            assertTrue(result.isSuccess(), "Expected RangeQuery to be handled 
on the native header store");
+            try (KeyValueIterator<String, String> iterator = 
result.getResult()) {
+                assertFalse(iterator.hasNext(), "Expected empty result from an 
empty store");
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
+        } finally {
+            store.close();
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"NATIVE", "IN_MEMORY"})
+    public void 
shouldReturnHeadersForTimestampedRangeWithHeadersQueryOnHeaderPersistingStore(final
 StoreType storeType) {
+        // Range counterpart of 
shouldReturnHeadersForTimestampedKeyWithHeadersQueryOnHeaderPersistingStore.
+        // A range query reads the underlying store (it never consults the 
cache), so use a store-served
+        // (caching-disabled) build; the native and in-memory builds persist 
headers, so every element carries them.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, false);
+        try {
+            final Headers headers = headersWith("h", "x");
+            store.put("k", ValueTimestampHeaders.make("v", 123L, headers));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess(), "Expected 
TimestampedRangeWithHeadersQuery to succeed");
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertTrue(iterator.hasNext());
+                final ReadOnlyRecord<String, String> record = iterator.next();
+                assertEquals("k", record.key());
+                assertEquals("v", record.value());
+                assertEquals(123L, record.timestamp());
+                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");
+                assertFalse(iterator.hasNext());
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldReturnEmptyHeadersForTimestampedRangeWithHeadersQueryOnAdapterStore() {
+        // The timestamped adapter keeps the timestamp but drops headers on 
write. A range query reads
+        // the underlying store (never the cache), so the headers always come 
back empty (never null) --
+        // unlike the point query, whose warm-cache read can still return them.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.ADAPTER, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess());
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertTrue(iterator.hasNext());
+                final ReadOnlyRecord<String, String> record = iterator.next();
+                assertEquals("k", record.key());
+                assertEquals("v", record.value());
+                assertEquals(123L, record.timestamp());
+                assertEquals(new RecordHeaders(), record.headers());
+                assertFalse(iterator.hasNext());
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldThrowForTimestampedRangeWithHeadersQueryOnPlainLegacyStore() {
+        // A plain (non-timestamped) legacy supplier surfaces every entry with 
timestamp = -1, which
+        // cannot be represented as a ReadOnlyRecord. A range query reads the 
underlying store, so the
+        // failure surfaces while iterating (there is no cache-served success 
path like the point query).
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.PLAIN_ADAPTER, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess(), "The range query itself succeeds; 
the failure surfaces while iterating");
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertThrows(StreamsException.class, iterator::next,

Review Comment:
   The negative-timestamp `StreamsException` is asserted only by type, never by 
message. If the message text matters, assert a fragment of it; otherwise fine 
to leave. Minor.



##########
streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java:
##########
@@ -188,57 +197,186 @@ public void 
shouldServeCacheHitWhenCachingEnabledAndRecordNotYetFlushed() throws
     }
 
     @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()));
+    public void 
shouldFailWithUnknownQueryTypeForKeyQueryAgainstNonHeadersStore() throws 
Exception {
+        
assertUnknownQueryTypeAgainstNonHeadersStore(TimestampedKeyWithHeadersQuery.<Integer,
 String>withKey(1));
+    }
 
-        kafkaStreams = new KafkaStreams(builder.build(), props());
-        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+    @Test
+    public void shouldHandleTimestampedRangeWithHeadersQuery() throws 
Exception {
+        // Caching disabled: a range query reads the underlying store directly 
(it never consults the

Review Comment:
   No integration test runs a range query with caching ENABLED. Since 
`CachingKeyValueStoreWithHeaders.query()` forwards straight to the store 
(bypassing the cache), a caching-enabled range query won't see writes not yet 
flushed — worth a test that locks in that behavior (the point query has its 
cache-hit counterpart).



##########
streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java:
##########
@@ -188,57 +197,186 @@ public void 
shouldServeCacheHitWhenCachingEnabledAndRecordNotYetFlushed() throws
     }
 
     @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()));
+    public void 
shouldFailWithUnknownQueryTypeForKeyQueryAgainstNonHeadersStore() throws 
Exception {
+        
assertUnknownQueryTypeAgainstNonHeadersStore(TimestampedKeyWithHeadersQuery.<Integer,
 String>withKey(1));
+    }
 
-        kafkaStreams = new KafkaStreams(builder.build(), props());
-        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+    @Test
+    public void shouldHandleTimestampedRangeWithHeadersQuery() throws 
Exception {
+        // Caching disabled: a range query reads the underlying store directly 
(it never consults the
+        // cache), so the writes must be store-served.
+        startStreamsWithHeadersStore();
 
-        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS, 
KeyValue.pair(1, "a0"));
+        // keys 1,2 (headers), key 3 (empty headers), key 4 written then 
tombstone
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
+            KeyValue.pair(1, "one"), KeyValue.pair(2, "two"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 1, new 
RecordHeaders(),
+            KeyValue.pair(3, "three"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 2, HEADERS,
+            KeyValue.pair(4, "four"), KeyValue.pair(4, null));
+
+        // Full scan, ascending: keys 1, 2, 3 (key 4 tombstone and omitted).
+        final List<ReadOnlyRecord<Integer, String>> ascending =
+            rangeQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds().withAscendingKeys());
+        assertEquals(List.of(1, 2, 3), keys(ascending));
+        assertEquals(List.of("one", "two", "three"), values(ascending));
+        // key/value/timestamp/headers carried on each element
+        assertEquals(HEADERS, ascending.get(0).headers());          // key 1
+        assertEquals(baseTimestamp, ascending.get(0).timestamp());
+        assertEquals(HEADERS, ascending.get(1).headers());          // key 2
+        assertEquals(baseTimestamp, ascending.get(1).timestamp());
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), ascending.get(2).headers());
+        assertEquals(baseTimestamp + 1, ascending.get(2).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
ascending.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
ascending.get(0).headers().remove("source"));
+
+        // Full scan, descending: keys reversed, payload still carried.
+        final List<ReadOnlyRecord<Integer, String>> descending =
+            rangeQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds().withDescendingKeys());
+        assertEquals(List.of(3, 2, 1), keys(descending));
+        assertEquals(List.of("three", "two", "one"), values(descending));
+        // key/value/timestamp/headers carried on each element
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), descending.get(0).headers());  // 
key 3
+        assertEquals(baseTimestamp + 1, descending.get(0).timestamp());
+        assertEquals(HEADERS, descending.get(1).headers());             // key 
2
+        assertEquals(baseTimestamp, descending.get(1).timestamp());
+        assertEquals(HEADERS, descending.get(2).headers());             // key 
1
+        assertEquals(baseTimestamp, descending.get(2).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
descending.get(2).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
descending.get(2).headers().remove("source"));
+
+        // Bounded ranges (inclusive on both ends); same per-element checks as 
the full scans.
+        // withRange(2, 3) -> keys 2, 3.
+        final List<ReadOnlyRecord<Integer, String>> range = 
rangeQuery(TimestampedRangeWithHeadersQuery.withRange(2, 3));
+        assertEquals(List.of(2, 3), keys(range));
+        assertEquals(List.of("two", "three"), values(range));
+        assertEquals(HEADERS, range.get(0).headers());                  // key 
2
+        assertEquals(baseTimestamp, range.get(0).timestamp());
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), range.get(1).headers());      // key 
3
+        assertEquals(baseTimestamp + 1, range.get(1).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
range.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
range.get(0).headers().remove("source"));
+
+        // withLowerBound(2) -> keys 2, 3.
+        final List<ReadOnlyRecord<Integer, String>> lowerBounded =
+            rangeQuery(TimestampedRangeWithHeadersQuery.withLowerBound(2));
+        assertEquals(List.of(2, 3), keys(lowerBounded));
+        assertEquals(List.of("two", "three"), values(lowerBounded));
+        assertEquals(HEADERS, lowerBounded.get(0).headers());           // key 
2
+        assertEquals(baseTimestamp, lowerBounded.get(0).timestamp());
+        assertEquals(new RecordHeaders(), lowerBounded.get(1).headers()); // 
key 3
+        assertEquals(baseTimestamp + 1, lowerBounded.get(1).timestamp());
+        assertThrows(IllegalStateException.class, () -> 
lowerBounded.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
lowerBounded.get(0).headers().remove("source"));
+
+        // withUpperBound(2) -> keys 1, 2.
+        final List<ReadOnlyRecord<Integer, String>> upperBounded =
+            rangeQuery(TimestampedRangeWithHeadersQuery.withUpperBound(2));
+        assertEquals(List.of(1, 2), keys(upperBounded));
+        assertEquals(List.of("one", "two"), values(upperBounded));
+        assertEquals(HEADERS, upperBounded.get(0).headers());           // key 
1
+        assertEquals(baseTimestamp, upperBounded.get(0).timestamp());
+        assertEquals(HEADERS, upperBounded.get(1).headers());           // key 
2
+        assertEquals(baseTimestamp, upperBounded.get(1).timestamp());
+        assertThrows(IllegalStateException.class, () -> 
upperBounded.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
upperBounded.get(0).headers().remove("source"));
+    }
 
-        final StateQueryRequest<ReadOnlyRecord<Integer, String>> request =
+    @Test
+    public void 
shouldFailWithUnknownQueryTypeForRangeQueryAgainstNonHeadersStore() throws 
Exception {
+        
assertUnknownQueryTypeAgainstNonHeadersStore(TimestampedRangeWithHeadersQuery.<Integer,
 String>withNoBounds());
+    }
+
+    @Test
+    public void 
shouldThrowForTimestampedRangeWithHeadersQueryOnPlainSupplier() throws 
Exception {
+        // The query succeeds, but iterating throws because timestamp = -1 
cannot be a ReadOnlyRecord.
+        startStreamsWithPlainSupplierStore();
+
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
+            KeyValue.pair(1, "one"), KeyValue.pair(2, "two"));
+
+        final StateQueryRequest<ReadOnlyRecordIterator<Integer, String>> 
request =
             inStore(STORE_NAME)
-                .withQuery(TimestampedKeyWithHeadersQuery.<Integer, 
String>withKey(1))
+                .withQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds())
                 .withPositionBound(PositionBound.at(inputPosition));
-        final StateQueryResult<ReadOnlyRecord<Integer, String>> result =
+        final StateQueryResult<ReadOnlyRecordIterator<Integer, String>> result 
=
             IntegrationTestUtils.iqv2WaitForResult(kafkaStreams, request);
 
-        assertTrue(result.getOnlyPartitionResult().isFailure());
-        assertEquals(FailureReason.UNKNOWN_QUERY_TYPE, 
result.getOnlyPartitionResult().getFailureReason());
+        final QueryResult<ReadOnlyRecordIterator<Integer, String>> onlyResult 
= result.getOnlyPartitionResult();
+        assertTrue(onlyResult.isSuccess());
+        try (ReadOnlyRecordIterator<Integer, String> iterator = 
onlyResult.getResult()) {
+            assertThrows(StreamsException.class, iterator::next);
+        }
+    }
+
+    private void startStreams(final StoreBuilder<?> storeBuilder,
+                              final ProcessorSupplier<Integer, String, 
Integer, String> processorSupplier) throws Exception {
+        final StreamsBuilder builder = new StreamsBuilder();
+        builder
+                .addStateStore(storeBuilder)
+                .stream(inputStream, Consumed.with(Serdes.Integer(), 
Serdes.String()))
+                .process(processorSupplier, STORE_NAME)
+                .to(outputStream, Produced.with(Serdes.Integer(), 
Serdes.String()));
+
+        kafkaStreams = new KafkaStreams(builder.build(), props());
+        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
     }
 
-    private void startStreams() throws Exception {
+    private void startStreamsWithHeadersStore() 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);
+        startStreamsWithHeadersStore(false);
     }
 
-    private void startStreams(final boolean cachingEnabled) throws Exception {
-        final StreamsBuilder builder = new StreamsBuilder();
+    private void startStreamsWithHeadersStore(final boolean cachingEnabled) 
throws Exception {
         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()));
+        startStreams(
+            cachingEnabled ? storeBuilder.withCachingEnabled() : 
storeBuilder.withCachingDisabled(),
+            HeadersStoreWriterProcessor::new);
+    }
 
-        kafkaStreams = new KafkaStreams(builder.build(), props());
-        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+    private void startStreamsWithNonHeadersStore() throws Exception {
+        // A plain (non-WithHeaders) timestamped store: the headers-aware 
query types are unsupported here.
+        startStreams(
+            Stores.timestampedKeyValueStoreBuilder(
+                Stores.persistentTimestampedKeyValueStore(STORE_NAME),
+                Serdes.Integer(),
+                Serdes.String()),
+            PlainStoreWriterProcessor::new);
+    }
+
+    private void startStreamsWithPlainSupplierStore() throws Exception {

Review Comment:
   The ADAPTER config isn't covered end-to-end: a WithHeaders builder over a 
plain *timestamped* supplier (persistentTimestampedKeyValueStore) keeps 
timestamps but drops headers, so a range query returns empty (not null) headers 
— a distinct outcome from the three helpers here, tested only at the store 
level. Consider a startStreamsWithAdapterStore helper + a range test asserting 
empty headers.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java:
##########
@@ -696,6 +736,91 @@ public K peekNextKey() {
         }
     }
 
+    /**
+     * Iterator backing {@link TimestampedRangeWithHeadersQuery}: yields each 
entry as a
+     * {@link ReadOnlyRecord} (implemented by {@link Record}) carrying key, 
value, timestamp, and the
+     * stored headers, with the headers frozen so a caller cannot mutate the 
read-only result.
+     *
+     * <p>A {@link ReadOnlyRecord} timestamp is contractually non-negative, so 
an entry with a negative
+     * stored timestamp cannot be represented. The dominant, deterministic 
cause is a store that does
+     * not persist timestamps: a {@code WithHeaders} store built over a plain 
{@link KeyValueStore}
+     * supplier surfaces every entry with {@code NO_TIMESTAMP} (-1). A genuine 
negative write is
+     * otherwise blocked (the source {@code RecordQueue} drops 
negative-timestamp records at ingestion),
+     * though {@link ValueAndTimestamp#make}/{@link 
ValueTimestampHeaders#make} do not themselves reject
+     * one written directly.
+     *
+     * <p>This mirrors the rule the point query {@link 
TimestampedKeyWithHeadersQuery} applies. But
+     * because a lazily-evaluated range has already returned a successful 
{@link QueryResult} before any
+     * entry is read, such an entry cannot be surfaced as a query-level 
failure; it is instead reported
+     * by throwing a {@link StreamsException} while advancing the iterator.
+     */
+    private class 
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator

Review Comment:
   This is now the third inner iterator in this file repeating the same 
scaffolding — fields, `increment()`/`openIterators.add()` in the ctor, and 
sensor-record + `decrement()`/`remove()` in close(). Consider extracting a 
shared abstract metered-iterator base so each subclass only implements next(). 
Optional (it matches the current idiom), but the duplication is growing.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java:
##########
@@ -696,6 +736,91 @@ public K peekNextKey() {
         }
     }
 
+    /**
+     * Iterator backing {@link TimestampedRangeWithHeadersQuery}: yields each 
entry as a
+     * {@link ReadOnlyRecord} (implemented by {@link Record}) carrying key, 
value, timestamp, and the
+     * stored headers, with the headers frozen so a caller cannot mutate the 
read-only result.
+     *
+     * <p>A {@link ReadOnlyRecord} timestamp is contractually non-negative, so 
an entry with a negative
+     * stored timestamp cannot be represented. The dominant, deterministic 
cause is a store that does
+     * not persist timestamps: a {@code WithHeaders} store built over a plain 
{@link KeyValueStore}
+     * supplier surfaces every entry with {@code NO_TIMESTAMP} (-1). A genuine 
negative write is
+     * otherwise blocked (the source {@code RecordQueue} drops 
negative-timestamp records at ingestion),
+     * though {@link ValueAndTimestamp#make}/{@link 
ValueTimestampHeaders#make} do not themselves reject
+     * one written directly.
+     *
+     * <p>This mirrors the rule the point query {@link 
TimestampedKeyWithHeadersQuery} applies. But
+     * because a lazily-evaluated range has already returned a successful 
{@link QueryResult} before any
+     * entry is read, such an entry cannot be surfaced as a query-level 
failure; it is instead reported
+     * by throwing a {@link StreamsException} while advancing the iterator.
+     */
+    private class 
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator
+        implements ReadOnlyRecordIterator<K, V>, MeteredIterator {
+
+        private final KeyValueIterator<Bytes, byte[]> iter;
+        private final Sensor sensor;
+        private final long startNs;
+        private final long startTimestampMs;
+        private final Function<byte[], ValueTimestampHeaders<V>> 
valueTimestampHeadersDeserializer;
+
+        private 
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator(
+            final KeyValueIterator<Bytes, byte[]> iter,
+            final Sensor sensor,
+            final Function<byte[], ValueTimestampHeaders<V>> 
valueTimestampHeadersDeserializer
+        ) {
+            this.iter = iter;
+            this.sensor = sensor;
+            this.valueTimestampHeadersDeserializer = 
valueTimestampHeadersDeserializer;
+            this.startNs = time.nanoseconds();
+            this.startTimestampMs = time.milliseconds();
+            numOpenIterators.increment();
+            openIterators.add(this);
+        }
+
+        @Override
+        public long startTimestamp() {
+            return startTimestampMs;
+        }
+
+        @Override
+        public boolean hasNext() {
+            return iter.hasNext();
+        }
+
+        @Override
+        public ReadOnlyRecord<K, V> next() {
+            final KeyValue<Bytes, byte[]> keyValue = iter.next();
+            final ValueTimestampHeaders<V> valueTimestampHeaders = 
valueTimestampHeadersDeserializer.apply(keyValue.value);
+            final Headers headers = valueTimestampHeaders.headers();
+            final K key = deserializeKey(keyValue.key.get(), headers);
+            if (valueTimestampHeaders.timestamp() < 0) {
+                throw new StreamsException(

Review Comment:
   This throw leaves the iterator open. For a plain/NO_TIMESTAMP store the 
throw is an expected outcome of normal iteration, so a caller that catches it 
and abandons the iterator leaks the underlying RocksDB iterator and permanently 
inflates `num-open-iterators`. Worth documenting that the caller must close in 
a finally/try-with-resources.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java:
##########
@@ -639,6 +677,7 @@ private 
MeteredTimestampedKeyValueStoreWithHeadersQueryIterator(
             this.startNs = time.nanoseconds();
             this.startTimestampMs = time.milliseconds();
             this.returnPlainValue = returnPlainValue;
+            numOpenIterators.increment();

Review Comment:
   This metric fix ships without a regression test. The only 
`num-open-iterators` test drives `all()`, which already tracked the gauge on 
trunk — nothing asserts the gauge moves for the range/IQ query iterator you fix 
here (or the new ReadOnlyRecordIterator). Add a 0→1→0 check over a range query 
iterator.



##########
streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java:
##########
@@ -615,4 +605,205 @@ public void 
shouldReturnIdenticalResultsForNativeAndAdapterBuiltStores(final boo
             adapterStore.close();
         }
     }
+
+    @ParameterizedTest
+    @ValueSource(booleans = {true, false})
+    public void shouldHandleRangeQuery(final boolean cachingEnabled) {
+        // KIP-1356: the native header store now serves RangeQuery 
(header-stripped results), matching
+        // the adapter build path (caching on or off).
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, cachingEnabled);
+        try {
+            final QueryResult<KeyValueIterator<String, String>> result =
+                store.query(RangeQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+            assertTrue(result.isSuccess(), "Expected RangeQuery to be handled 
on the native header store");
+            try (KeyValueIterator<String, String> iterator = 
result.getResult()) {
+                assertFalse(iterator.hasNext(), "Expected empty result from an 
empty store");
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
+        } finally {
+            store.close();
+        }
+    }
+
+    @ParameterizedTest
+    @CsvSource({"NATIVE", "IN_MEMORY"})
+    public void 
shouldReturnHeadersForTimestampedRangeWithHeadersQueryOnHeaderPersistingStore(final
 StoreType storeType) {
+        // Range counterpart of 
shouldReturnHeadersForTimestampedKeyWithHeadersQueryOnHeaderPersistingStore.
+        // A range query reads the underlying store (it never consults the 
cache), so use a store-served
+        // (caching-disabled) build; the native and in-memory builds persist 
headers, so every element carries them.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(storeType, false);
+        try {
+            final Headers headers = headersWith("h", "x");
+            store.put("k", ValueTimestampHeaders.make("v", 123L, headers));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess(), "Expected 
TimestampedRangeWithHeadersQuery to succeed");
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertTrue(iterator.hasNext());
+                final ReadOnlyRecord<String, String> record = iterator.next();
+                assertEquals("k", record.key());
+                assertEquals("v", record.value());
+                assertEquals(123L, record.timestamp());
+                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");
+                assertFalse(iterator.hasNext());
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldReturnEmptyHeadersForTimestampedRangeWithHeadersQueryOnAdapterStore() {
+        // The timestamped adapter keeps the timestamp but drops headers on 
write. A range query reads
+        // the underlying store (never the cache), so the headers always come 
back empty (never null) --
+        // unlike the point query, whose warm-cache read can still return them.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.ADAPTER, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess());
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertTrue(iterator.hasNext());
+                final ReadOnlyRecord<String, String> record = iterator.next();
+                assertEquals("k", record.key());
+                assertEquals("v", record.value());
+                assertEquals(123L, record.timestamp());
+                assertEquals(new RecordHeaders(), record.headers());
+                assertFalse(iterator.hasNext());
+            }
+            assertNotNull(result.getPosition(), "Expected position to be set");
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldThrowForTimestampedRangeWithHeadersQueryOnPlainLegacyStore() {
+        // A plain (non-timestamped) legacy supplier surfaces every entry with 
timestamp = -1, which
+        // cannot be represented as a ReadOnlyRecord. A range query reads the 
underlying store, so the
+        // failure surfaces while iterating (there is no cache-served success 
path like the point query).
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.PLAIN_ADAPTER, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess(), "The range query itself succeeds; 
the failure surfaces while iterating");
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertThrows(StreamsException.class, iterator::next,
+                    "An entry with ts=-1 cannot be represented as a 
ReadOnlyRecord");
+            }
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldThrowForNegativeStoredTimestampForTimestampedRangeWithHeadersQuery() {
+        // A caller can store a negative timestamp directly. Unlike the point 
query (which fails the whole
+        // query with STORE_EXCEPTION), a lazily-evaluated range iterator has 
already been returned, so the
+        // failure surfaces by throwing while advancing.
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", -1L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess());
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertThrows(StreamsException.class, iterator::next);
+            }
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldCollectExecutionInfoForTimestampedRangeWithHeadersQueryWhenRequested() {
+        // The range query, run through the metered handler with execution 
info enabled, must carry both
+        // the wrapped store's entry and the metered handler's entry. (Unlike 
the point query there is no
+        // query-level failure path -- a negative timestamp surfaces while 
iterating, not as a failed result.)
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(true));
+
+            assertTrue(result.isSuccess());
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                final String info = String.join("\n", 
result.getExecutionInfo());
+                assertTrue(
+                    
info.contains(RocksDBTimestampedStoreWithHeaders.class.getName())
+                        && 
info.contains(MeteredTimestampedKeyValueStoreWithHeaders.class.getName()),
+                    "execution info missing an entry: " + info);
+            }
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldNotCollectExecutionInfoForTimestampedRangeWithHeadersQueryWhenNotRequested()
 {
+        final TimestampedKeyValueStoreWithHeaders<String, String> store = 
buildAndInitStore(StoreType.NATIVE, false);
+        try {
+            store.put("k", ValueTimestampHeaders.make("v", 123L, 
headersWith("h", "x")));
+
+            final QueryResult<ReadOnlyRecordIterator<String, String>> result =
+                store.query(TimestampedRangeWithHeadersQuery.withNoBounds(), 
PositionBound.unbounded(), new QueryConfig(false));
+
+            assertTrue(result.isSuccess());
+            try (ReadOnlyRecordIterator<String, String> iterator = 
result.getResult()) {
+                assertTrue(result.getExecutionInfo().isEmpty(), "Expected no 
execution info: " + result.getExecutionInfo());
+            }
+        } finally {
+            store.close();
+        }
+    }
+
+    @Test
+    public void 
shouldReturnIdenticalRangeResultsForNativeAndAdapterBuiltStores() {

Review Comment:
   Parity-only with a single record: it checks native == adapter but won't 
catch a bug symmetric across both paths, and doesn't pin ordering or 
multi-element results. Consider a few keys with an expected order.



##########
streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java:
##########
@@ -302,6 +305,39 @@ public void 
shouldGetRangeFromInnerStoreAndRecordRangeMetric() {
         assertTrue((Double) metric.metricValue() > 0);
     }
 
+    @Test
+    public void 
shouldPropagateBoundsAndDescendingOrderForTimestampedRangeWithHeadersQuery() {

Review Comment:
   These two capture tests only cover `lowerBound`+DESCENDING and 
`upperBound`+ASCENDING. The default `ResultOrder.ANY` (no order builder) and 
the both-bounds `withRange` case aren't asserted here — add a 
`withNoBounds`/`withRange` capture check so the ANY path is pinned at this 
level.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java:
##########
@@ -696,6 +736,91 @@ public K peekNextKey() {
         }
     }
 
+    /**
+     * Iterator backing {@link TimestampedRangeWithHeadersQuery}: yields each 
entry as a
+     * {@link ReadOnlyRecord} (implemented by {@link Record}) carrying key, 
value, timestamp, and the
+     * stored headers, with the headers frozen so a caller cannot mutate the 
read-only result.
+     *
+     * <p>A {@link ReadOnlyRecord} timestamp is contractually non-negative, so 
an entry with a negative
+     * stored timestamp cannot be represented. The dominant, deterministic 
cause is a store that does
+     * not persist timestamps: a {@code WithHeaders} store built over a plain 
{@link KeyValueStore}
+     * supplier surfaces every entry with {@code NO_TIMESTAMP} (-1). A genuine 
negative write is
+     * otherwise blocked (the source {@code RecordQueue} drops 
negative-timestamp records at ingestion),
+     * though {@link ValueAndTimestamp#make}/{@link 
ValueTimestampHeaders#make} do not themselves reject
+     * one written directly.
+     *
+     * <p>This mirrors the rule the point query {@link 
TimestampedKeyWithHeadersQuery} applies. But
+     * because a lazily-evaluated range has already returned a successful 
{@link QueryResult} before any
+     * entry is read, such an entry cannot be surfaced as a query-level 
failure; it is instead reported
+     * by throwing a {@link StreamsException} while advancing the iterator.
+     */
+    private class 
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator
+        implements ReadOnlyRecordIterator<K, V>, MeteredIterator {
+
+        private final KeyValueIterator<Bytes, byte[]> iter;
+        private final Sensor sensor;
+        private final long startNs;
+        private final long startTimestampMs;
+        private final Function<byte[], ValueTimestampHeaders<V>> 
valueTimestampHeadersDeserializer;
+
+        private 
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator(
+            final KeyValueIterator<Bytes, byte[]> iter,
+            final Sensor sensor,
+            final Function<byte[], ValueTimestampHeaders<V>> 
valueTimestampHeadersDeserializer
+        ) {
+            this.iter = iter;
+            this.sensor = sensor;
+            this.valueTimestampHeadersDeserializer = 
valueTimestampHeadersDeserializer;
+            this.startNs = time.nanoseconds();
+            this.startTimestampMs = time.milliseconds();
+            numOpenIterators.increment();
+            openIterators.add(this);
+        }
+
+        @Override
+        public long startTimestamp() {
+            return startTimestampMs;
+        }
+
+        @Override
+        public boolean hasNext() {
+            return iter.hasNext();
+        }
+
+        @Override
+        public ReadOnlyRecord<K, V> next() {
+            final KeyValue<Bytes, byte[]> keyValue = iter.next();
+            final ValueTimestampHeaders<V> valueTimestampHeaders = 
valueTimestampHeadersDeserializer.apply(keyValue.value);
+            final Headers headers = valueTimestampHeaders.headers();
+            final K key = deserializeKey(keyValue.key.get(), headers);
+            if (valueTimestampHeaders.timestamp() < 0) {
+                throw new StreamsException(
+                    "Cannot represent the stored record for key [" + key + "] 
as a ReadOnlyRecord: its "
+                        + "timestamp (" + valueTimestampHeaders.timestamp() + 
") is negative.");
+            }
+            final Record<K, V> record = new Record<>(
+                key,
+                valueTimestampHeaders.value(),
+                valueTimestampHeaders.timestamp(),
+                headers);
+            ((RecordHeaders) record.headers()).setReadOnly();
+            return record;
+        }
+
+        @Override
+        public void close() {
+            try {
+                iter.close();
+            } finally {
+                final long duration = time.nanoseconds() - startNs;
+                sensor.record(duration);
+                iteratorDurationSensor.record(duration);
+                numOpenIterators.decrement();

Review Comment:
   No test covers the gauge on the misuse paths: a second `close()` 
double-decrements `numOpenIterators`, and a `next()` that throws and isn't 
closed never decrements. If you want the gauge behavior pinned, add a test for 
each.



##########
streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java:
##########
@@ -188,57 +197,186 @@ public void 
shouldServeCacheHitWhenCachingEnabledAndRecordNotYetFlushed() throws
     }
 
     @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()));
+    public void 
shouldFailWithUnknownQueryTypeForKeyQueryAgainstNonHeadersStore() throws 
Exception {
+        
assertUnknownQueryTypeAgainstNonHeadersStore(TimestampedKeyWithHeadersQuery.<Integer,
 String>withKey(1));
+    }
 
-        kafkaStreams = new KafkaStreams(builder.build(), props());
-        IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+    @Test
+    public void shouldHandleTimestampedRangeWithHeadersQuery() throws 
Exception {
+        // Caching disabled: a range query reads the underlying store directly 
(it never consults the
+        // cache), so the writes must be store-served.
+        startStreamsWithHeadersStore();
 
-        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS, 
KeyValue.pair(1, "a0"));
+        // keys 1,2 (headers), key 3 (empty headers), key 4 written then 
tombstone
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
+            KeyValue.pair(1, "one"), KeyValue.pair(2, "two"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 1, new 
RecordHeaders(),
+            KeyValue.pair(3, "three"));
+        produceDataToTopicWithHeaders(inputStream, baseTimestamp + 2, HEADERS,
+            KeyValue.pair(4, "four"), KeyValue.pair(4, null));
+
+        // Full scan, ascending: keys 1, 2, 3 (key 4 tombstone and omitted).
+        final List<ReadOnlyRecord<Integer, String>> ascending =
+            rangeQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds().withAscendingKeys());
+        assertEquals(List.of(1, 2, 3), keys(ascending));
+        assertEquals(List.of("one", "two", "three"), values(ascending));
+        // key/value/timestamp/headers carried on each element
+        assertEquals(HEADERS, ascending.get(0).headers());          // key 1
+        assertEquals(baseTimestamp, ascending.get(0).timestamp());
+        assertEquals(HEADERS, ascending.get(1).headers());          // key 2
+        assertEquals(baseTimestamp, ascending.get(1).timestamp());
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), ascending.get(2).headers());
+        assertEquals(baseTimestamp + 1, ascending.get(2).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
ascending.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
ascending.get(0).headers().remove("source"));
+
+        // Full scan, descending: keys reversed, payload still carried.
+        final List<ReadOnlyRecord<Integer, String>> descending =
+            rangeQuery(TimestampedRangeWithHeadersQuery.<Integer, 
String>withNoBounds().withDescendingKeys());
+        assertEquals(List.of(3, 2, 1), keys(descending));
+        assertEquals(List.of("three", "two", "one"), values(descending));
+        // key/value/timestamp/headers carried on each element
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), descending.get(0).headers());  // 
key 3
+        assertEquals(baseTimestamp + 1, descending.get(0).timestamp());
+        assertEquals(HEADERS, descending.get(1).headers());             // key 
2
+        assertEquals(baseTimestamp, descending.get(1).timestamp());
+        assertEquals(HEADERS, descending.get(2).headers());             // key 
1
+        assertEquals(baseTimestamp, descending.get(2).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
descending.get(2).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
descending.get(2).headers().remove("source"));
+
+        // Bounded ranges (inclusive on both ends); same per-element checks as 
the full scans.
+        // withRange(2, 3) -> keys 2, 3.
+        final List<ReadOnlyRecord<Integer, String>> range = 
rangeQuery(TimestampedRangeWithHeadersQuery.withRange(2, 3));
+        assertEquals(List.of(2, 3), keys(range));
+        assertEquals(List.of("two", "three"), values(range));
+        assertEquals(HEADERS, range.get(0).headers());                  // key 
2
+        assertEquals(baseTimestamp, range.get(0).timestamp());
+        // key 3 written without headers -> empty (never null) headers
+        assertEquals(new RecordHeaders(), range.get(1).headers());      // key 
3
+        assertEquals(baseTimestamp + 1, range.get(1).timestamp());
+        // returned headers are a read-only snapshot: no mutation (add or 
remove) is allowed
+        assertThrows(IllegalStateException.class, () -> 
range.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
range.get(0).headers().remove("source"));
+
+        // withLowerBound(2) -> keys 2, 3.
+        final List<ReadOnlyRecord<Integer, String>> lowerBounded =
+            rangeQuery(TimestampedRangeWithHeadersQuery.withLowerBound(2));
+        assertEquals(List.of(2, 3), keys(lowerBounded));
+        assertEquals(List.of("two", "three"), values(lowerBounded));
+        assertEquals(HEADERS, lowerBounded.get(0).headers());           // key 
2
+        assertEquals(baseTimestamp, lowerBounded.get(0).timestamp());
+        assertEquals(new RecordHeaders(), lowerBounded.get(1).headers()); // 
key 3
+        assertEquals(baseTimestamp + 1, lowerBounded.get(1).timestamp());
+        assertThrows(IllegalStateException.class, () -> 
lowerBounded.get(0).headers().add("x", new byte[0]));
+        assertThrows(IllegalStateException.class, () -> 
lowerBounded.get(0).headers().remove("source"));
+
+        // withUpperBound(2) -> keys 1, 2.
+        final List<ReadOnlyRecord<Integer, String>> upperBounded =
+            rangeQuery(TimestampedRangeWithHeadersQuery.withUpperBound(2));

Review Comment:
   No range test asserts an empty result (a bound matching no keys). A quick 
empty-range case would cover the no-match path.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to