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 a49d127f5b9 KAFKA-20704: IQv2 TimestampedRangeWithHeadersQuery for
headers-aware key-value stores (KIP-1356) (#22770)
a49d127f5b9 is described below
commit a49d127f5b901e9b93391f75d8efedc12857d6cd
Author: Jess668 <[email protected]>
AuthorDate: Tue Jul 14 04:31:51 2026 -0400
KAFKA-20704: IQv2 TimestampedRangeWithHeadersQuery for headers-aware
key-value stores (KIP-1356) (#22770)
Implements **`TimestampedRangeWithHeadersQuery`**, the second IQv2 query
type of KIP-1356, whose result is an iterator of records that each carry
key, value, timestamp, **and headers**. The result type is the
**`ReadOnlyRecordIterator<K, V>`** — a `Closeable` iterator that yields
`ReadOnlyRecord<K, V>` elements directly.
## Dependencies
This work is stacked on, and should be reviewed/merged after:
1. `ReadOnlyRecord`
[PR#22677](https://github.com/apache/kafka/pull/22677) — already in
`trunk`.
2. `ReadOnlyRecordIterator`
[PR#22729](https://github.com/apache/kafka/pull/22729) — already in
`trunk`.
3. `TimestampedKeyWithHeadersQuery`
[PR#22666](https://github.com/apache/kafka/pull/22666) — already in
`trunk`.
All three have landed; this branch has been rebased onto `trunk` and
contains only the range commits.
## Summary
1. **`TimestampedRangeWithHeadersQuery<K, V> implements
Query<ReadOnlyRecordIterator<K, V>>`** (`@Evolving` /
`@InterfaceAudience.Public`). Mirrors `TimestampedRangeQuery`: static
factories `withRange`, `withUpperBound`, `withLowerBound`,
`withNoBounds`; order builders `withDescendingKeys` /
`withAscendingKeys`; accessors `lowerBound()`, `upperBound()`,
`resultOrder()`. Same bound/order semantics as `TimestampedRangeQuery`.
2. `MeteredTimestampedKeyValueStoreWithHeaders` registers a
`TimestampedRangeWithHeadersQuery` handler and returns a
`ReadOnlyRecordIterator` whose elements are built as immutable
`ReadOnlyRecord`s (headers frozen via `setReadOnly()`). The bounds+order
→ raw `RangeQuery` construction shared by the three range handlers is
factored into a `rawRangeQuery(...)` helper.
3. The shared open/close/gauge scaffolding across the three metered
iterator classes in `MeteredTimestampedKeyValueStoreWithHeaders` is
factored into an `AbstractMeteredIterator` base. The
`MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator` now
documents that a caller must still `close()` it in a
`finally`/try-with-resources even when `next()` throws for a
negative-timestamp entry, or the gauge and the underlying iterator both
leak.
4. **`num-open-iterators` metric fix**: the IQ range iterators
(`QueryIterator`, used by `RangeQuery`/`TimestampedRangeQuery`, and the
new `ReadOnlyRecordIterator` for this query) registered in
`openIterators` but never updated `numOpenIterators`, so they were
invisible to the `num-open-iterators` gauge. Both now increment on open
/ decrement on close, matching the base `MeteredKeyValueStoreIterator`.
5. **Native store enablement**: `RocksDBTimestampedStoreWithHeaders`
drops its scope-to-`KeyQuery`-only `query()` override, falling back to
the inherited `RocksDBStore` handling, which serves `RangeQuery`.
Previously the native header store returned `UNKNOWN_QUERY_TYPE` for
range queries.
## Testing
- **Unit** — `MeteredTimestampedKeyValueStoreWithHeadersTest`: bounds
(as serialized keys) and ascending/descending order are propagated to
the underlying raw `RangeQuery`.
- **Builder / end-to-end** —
`TimestampedKeyValueStoreBuilderWithHeadersTest`: `RangeQuery` on the
native store; `TimestampedRangeWithHeadersQuery` returns exact headers
on a header-persisting store, empty headers on the adapter store, throws
on a plain/legacy store, throws on a negative stored timestamp;
execution-info collect/no-collect; identical range results across
native- and adapter-built stores.
- **Native store** — `RocksDBTimestampedStoreWithHeadersTest`:
`RangeQuery` is now handled (was `UNKNOWN_QUERY_TYPE`).
- **Integration** — `IQv2HeadersStoreIntegrationTest`:
`TimestampedRangeWithHeadersQuery` over a running topology returns each
record's correct key/value/timestamp/headers across order and bound
variants; `UNKNOWN_QUERY_TYPE` against a non-headers store; failure
against a plain supplier.
## Follow-ups
- The sibling `MeteredTimestampedKeyValueStore` (non-headers store) has
the same `num-open-iterators` gauge gap fixed here; filing a separate
JIRA [KAFKA-20796](https://issues.apache.org/jira/browse/KAFKA-20796) to
track it rather than fixing it in this PR.
Reviewers: Alieh Saeedi <[email protected]>, TengYao Chi
<[email protected]>
---
.../IQv2HeadersStoreIntegrationTest.java | 346 ++++++++++++++++++---
.../query/TimestampedRangeWithHeadersQuery.java | 156 ++++++++++
...MeteredTimestampedKeyValueStoreWithHeaders.java | 247 ++++++++++-----
.../RocksDBTimestampedStoreWithHeaders.java | 34 --
...redTimestampedKeyValueStoreWithHeadersTest.java | 177 ++++++++++-
.../RocksDBTimestampedStoreWithHeadersTest.java | 27 +-
...stampedKeyValueStoreBuilderWithHeadersTest.java | 251 +++++++++++++--
7 files changed, 1048 insertions(+), 190 deletions(-)
diff --git
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
index f8eb62da2e1..9b1442883ca 100644
---
a/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
+++
b/streams/integration-tests/src/test/java/org/apache/kafka/streams/integration/IQv2HeadersStoreIntegrationTest.java
@@ -30,21 +30,26 @@ import org.apache.kafka.streams.KafkaStreams;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.StreamsBuilder;
import org.apache.kafka.streams.StreamsConfig;
+import org.apache.kafka.streams.errors.StreamsException;
import org.apache.kafka.streams.integration.utils.EmbeddedKafkaCluster;
import org.apache.kafka.streams.integration.utils.IntegrationTestUtils;
import org.apache.kafka.streams.kstream.Consumed;
import org.apache.kafka.streams.kstream.Produced;
import org.apache.kafka.streams.processor.api.Processor;
import org.apache.kafka.streams.processor.api.ProcessorContext;
+import org.apache.kafka.streams.processor.api.ProcessorSupplier;
import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
import org.apache.kafka.streams.processor.api.Record;
import org.apache.kafka.streams.query.FailureReason;
import org.apache.kafka.streams.query.Position;
import org.apache.kafka.streams.query.PositionBound;
+import org.apache.kafka.streams.query.Query;
import org.apache.kafka.streams.query.QueryResult;
import org.apache.kafka.streams.query.StateQueryRequest;
import org.apache.kafka.streams.query.StateQueryResult;
import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
+import org.apache.kafka.streams.query.TimestampedRangeWithHeadersQuery;
+import org.apache.kafka.streams.state.ReadOnlyRecordIterator;
import org.apache.kafka.streams.state.StoreBuilder;
import org.apache.kafka.streams.state.Stores;
import org.apache.kafka.streams.state.TimestampedKeyValueStore;
@@ -63,24 +68,28 @@ import org.junit.jupiter.api.TestInfo;
import java.io.IOException;
import java.time.Duration;
+import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import static org.apache.kafka.streams.query.StateQueryRequest.inStore;
import static org.apache.kafka.streams.utils.TestUtils.safeUniqueTestName;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* IQv2 integration tests for KIP-1271/KIP-1356 headers-aware state stores.
*
* <p>It builds a KIP-1271 {@code WithHeaders} store, writes records (with
headers) into it through a processor,
- * and queries it through IQv2. Currently covers {@link
TimestampedKeyWithHeadersQuery}; the remaining KIP-1356 headers
- * query types (range/window/session) are expected to extend this class as
they land.
+ * and queries it through IQv2. Covers {@link TimestampedKeyWithHeadersQuery}
and
+ * {@link TimestampedRangeWithHeadersQuery}; the remaining KIP-1356 headers
query types (window/session) are
+ * expected to extend this class as they land.
*/
@Tag("integration")
public class IQv2HeadersStoreIntegrationTest {
@@ -133,7 +142,7 @@ public class IQv2HeadersStoreIntegrationTest {
@Test
public void shouldHandleTimestampedKeyWithHeadersQuery() throws Exception {
- startStreams();
+ startStreamsWithKeyValueHeadersStore();
// key 1 has headers, key 2 has empty headers, key 3 is tombstoned
(null value)
produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
@@ -144,24 +153,24 @@ public class IQv2HeadersStoreIntegrationTest {
KeyValue.pair(3, "c0"), KeyValue.pair(3, null));
// key 1: key + value + timestamp + headers round-trip
- final ReadOnlyRecord<Integer, String> result1 = query(1);
+ final ReadOnlyRecord<Integer, String> result1 = keyQuery(1);
assertEquals(Integer.valueOf(1), result1.key());
assertEquals("a0", result1.value());
assertEquals(baseTimestamp, result1.timestamp());
assertEquals(HEADERS, result1.headers());
// key 2: written with no headers -> empty (never null) headers
- final ReadOnlyRecord<Integer, String> result2 = query(2);
+ final ReadOnlyRecord<Integer, String> result2 = keyQuery(2);
assertEquals(Integer.valueOf(2), result2.key());
assertEquals("b0", result2.value());
assertEquals(baseTimestamp + 1, result2.timestamp());
assertEquals(new RecordHeaders(), result2.headers());
// key 3: tombstoned -> null result, never a partially-populated
wrapper
- assertNull(query(3));
+ assertNull(keyQuery(3));
// never-written key -> null result
- assertNull(query(999));
+ assertNull(keyQuery(999));
}
@Test
@@ -171,16 +180,16 @@ public class IQv2HeadersStoreIntegrationTest {
// successful query therefore proves the result was served from the
cache via
// CachingKeyValueStoreWithHeaders -> the metered store's cache-hit
path, end-to-end.
commitIntervalMs = Duration.ofMinutes(10).toMillis();
- startStreams(true);
+ startStreamsWithKeyValueHeadersStore(true);
produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
KeyValue.pair(1, "a0"));
// Read-your-writes: the not-yet-flushed record is visible (served
from the cache), with headers.
- final ReadOnlyRecord<Integer, String> result = query(1);
+ final ReadOnlyRecord<Integer, String> result = keyQuery(1);
// skipCache bypasses the cache and reads the persistent store
directly. A null result positively
// proves nothing has been flushed, so the read above was genuinely
cache-served (not an accidental
// store read) -- and it covers skipCache end-to-end.
- assertNull(querySkipCache(1));
+ assertNull(keyQuerySkipCache(1));
assertEquals(Integer.valueOf(1), result.key());
assertEquals("a0", result.value());
assertEquals(baseTimestamp, result.timestamp());
@@ -188,60 +197,277 @@ public class IQv2HeadersStoreIntegrationTest {
}
@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.
+ startStreamsWithKeyValueHeadersStore();
+
+ // 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"));
+
+ // withRange(2, 2), equal bounds -> key 2 only (bounds are inclusive
on both ends).
+ final List<ReadOnlyRecord<Integer, String>> equalBounds =
rangeQuery(TimestampedRangeWithHeadersQuery.withRange(2, 2));
+ assertEquals(List.of(2), keys(equalBounds));
+ assertEquals(List.of("two"), values(equalBounds));
+ assertEquals(HEADERS, equalBounds.get(0).headers());
+ assertEquals(baseTimestamp, equalBounds.get(0).timestamp());
+ assertThrows(IllegalStateException.class, () ->
equalBounds.get(0).headers().add("x", new byte[0]));
+ assertThrows(IllegalStateException.class, () ->
equalBounds.get(0).headers().remove("source"));
+
+ // withRange(2, 3), descending -> keys 3, 2.
+ final List<ReadOnlyRecord<Integer, String>> rangeDescending =
+ rangeQuery(TimestampedRangeWithHeadersQuery.<Integer,
String>withRange(2, 3).withDescendingKeys());
+ assertEquals(List.of(3, 2), keys(rangeDescending));
+ assertEquals(List.of("three", "two"), values(rangeDescending));
+ // key 3 written without headers -> empty (never null) headers
+ assertEquals(new RecordHeaders(), rangeDescending.get(0).headers());
// key 3
+ assertEquals(baseTimestamp + 1, rangeDescending.get(0).timestamp());
+ assertEquals(HEADERS, rangeDescending.get(1).headers()); //
key 2
+ assertEquals(baseTimestamp, rangeDescending.get(1).timestamp());
+ // returned headers are a read-only snapshot: no mutation (add or
remove) is allowed
+ assertThrows(IllegalStateException.class, () ->
rangeDescending.get(1).headers().add("x", new byte[0]));
+ assertThrows(IllegalStateException.class, () ->
rangeDescending.get(1).headers().remove("source"));
+
+ // A bound matching no keys -> empty result (the no-match path).
+ final List<ReadOnlyRecord<Integer, String>> noMatch =
+ rangeQuery(TimestampedRangeWithHeadersQuery.withRange(4, 200));
+ assertTrue(noMatch.isEmpty());
+
+ // withRange(3, 2), lower > upper -> empty result (an inverted range
matches nothing, rather than failing).
+ final List<ReadOnlyRecord<Integer, String>> invertedBounds =
rangeQuery(TimestampedRangeWithHeadersQuery.withRange(3, 2));
+ assertTrue(invertedBounds.isEmpty());
+ }
+
+ @Test
+ public void shouldNotSeeUnflushedWriteInRangeQueryWhenCachingEnabled()
throws Exception {
+ // Unlike the point query (CachingKeyValueStoreWithHeaders serves
KeyQuery/TimestampedKeyWithHeadersQuery
+ // from the cache), CachingKeyValueStoreWithHeaders.query() forwards
RangeQuery/TimestampedRangeWithHeadersQuery
+ // straight to the underlying store, bypassing the cache. That
underlying store's Position is only
+ // advanced by writes that actually reach it, so a range query bound
on the input position never catches
+ // up while the write lives only in the cache -- it fails
NOT_UP_TO_BOUND, rather than returning an empty
+ // (but successful) result. Use a very large commit interval so the
record is never flushed during the test.
+ commitIntervalMs = Duration.ofMinutes(10).toMillis();
+ startStreamsWithKeyValueHeadersStore(true);
produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
KeyValue.pair(1, "a0"));
- final StateQueryRequest<ReadOnlyRecord<Integer, String>> request =
+ final StateQueryRequest<ReadOnlyRecordIterator<Integer, String>>
request =
+ inStore(STORE_NAME)
+ .withQuery(TimestampedRangeWithHeadersQuery.<Integer,
String>withNoBounds())
+ .withPositionBound(PositionBound.at(inputPosition));
+ final StateQueryResult<ReadOnlyRecordIterator<Integer, String>> result
= kafkaStreams.query(request);
+
+ final QueryResult<ReadOnlyRecordIterator<Integer, String>> onlyResult
= result.getOnlyPartitionResult();
+ assertTrue(onlyResult.isFailure(), "A range query bound on the input
position must not catch up while the write is cache-only");
+ assertEquals(FailureReason.NOT_UP_TO_BOUND,
onlyResult.getFailureReason());
+ }
+
+ @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.
+ startStreamsWithKeyValuePlainSupplierStore();
+
+ 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);
+ }
+ }
+
+ @Test
+ public void
shouldReturnEmptyHeadersForTimestampedRangeWithHeadersQueryOnAdapterStore()
throws Exception {
+ // A WithHeaders builder over a plain *timestamped* supplier
(persistentTimestampedKeyValueStore)
+ // keeps timestamps but drops headers -- distinct from the
plain-supplier build above, which can't
+ // even represent the timestamp. A range query reads the underlying
store directly, so headers
+ // come back empty (never null), even though they were written.
+ startStreamsWithKeyValueAdapterStore();
+
+ produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
KeyValue.pair(1, "one"));
+
+ final List<ReadOnlyRecord<Integer, String>> range =
+ rangeQuery(TimestampedRangeWithHeadersQuery.<Integer,
String>withNoBounds());
+ assertEquals(List.of(1), keys(range));
+ assertEquals(List.of("one"), values(range));
+ assertEquals(baseTimestamp, range.get(0).timestamp());
+ assertEquals(new RecordHeaders(), range.get(0).headers());
+ // returned headers are a read-only snapshot: no mutation (add or
remove) is allowed, even when empty
+ assertThrows(IllegalStateException.class, () ->
range.get(0).headers().add("x", new byte[0]));
+ assertThrows(IllegalStateException.class, () ->
range.get(0).headers().remove("source"));
+ }
+
+ 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 startStreamsWithKeyValueHeadersStore() 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);
+ startStreamsWithKeyValueHeadersStore(false);
}
- private void startStreams(final boolean cachingEnabled) throws Exception {
- final StreamsBuilder builder = new StreamsBuilder();
+ private void startStreamsWithKeyValueHeadersStore(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(),
+ KeyValueHeadersStoreWriterProcessor::new);
+ }
- kafkaStreams = new KafkaStreams(builder.build(), props());
- IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams);
+ private void startStreamsWithKeyValueNonHeadersStore() 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()),
+ KeyValuePlainStoreWriterProcessor::new);
+ }
+
+ private void startStreamsWithKeyValuePlainSupplierStore() throws Exception
{
+ // A WithHeaders builder over a plain (non-timestamped) supplier:
entries come back with
+ // timestamp = -1, which cannot be represented as a ReadOnlyRecord.
+ startStreams(
+ Stores.timestampedKeyValueStoreWithHeadersBuilder(
+ Stores.persistentKeyValueStore(STORE_NAME),
+ Serdes.Integer(),
+ Serdes.String()).withCachingDisabled(),
+ KeyValueHeadersStoreWriterProcessor::new);
+ }
+
+ private void startStreamsWithKeyValueAdapterStore() throws Exception {
+ // A WithHeaders builder over a plain *timestamped* supplier: keeps
timestamps (unlike the
+ // plain-supplier build above) but drops headers via
TimestampedToHeadersStoreAdapter.
+ startStreams(
+ Stores.timestampedKeyValueStoreWithHeadersBuilder(
+ Stores.persistentTimestampedKeyValueStore(STORE_NAME),
+ Serdes.Integer(),
+ Serdes.String()).withCachingDisabled(),
+ KeyValueHeadersStoreWriterProcessor::new);
+ }
+
+ private <R> void assertUnknownQueryTypeAgainstNonHeadersStore(final
Query<R> query) throws Exception {
+ startStreamsWithKeyValueNonHeadersStore();
+ produceDataToTopicWithHeaders(inputStream, baseTimestamp, HEADERS,
KeyValue.pair(1, "a0"));
+
+ final StateQueryRequest<R> request =
+
inStore(STORE_NAME).withQuery(query).withPositionBound(PositionBound.at(inputPosition));
+ final StateQueryResult<R> result =
IntegrationTestUtils.iqv2WaitForResult(kafkaStreams, request);
+
+ assertTrue(result.getOnlyPartitionResult().isFailure());
+ assertEquals(FailureReason.UNKNOWN_QUERY_TYPE,
result.getOnlyPartitionResult().getFailureReason());
}
- private ReadOnlyRecord<Integer, String> query(final int key) {
+ private ReadOnlyRecord<Integer, String> keyQuery(final int key) {
final StateQueryRequest<ReadOnlyRecord<Integer, String>> request =
inStore(STORE_NAME)
.withQuery(TimestampedKeyWithHeadersQuery.<Integer,
String>withKey(key))
@@ -256,7 +482,7 @@ public class IQv2HeadersStoreIntegrationTest {
return onlyResult == null ? null : onlyResult.getResult();
}
- private ReadOnlyRecord<Integer, String> querySkipCache(final int key) {
+ private ReadOnlyRecord<Integer, String> keyQuerySkipCache(final int key) {
// skipCache forwards the query past the record cache to the
persistent store. Use the default
// (unbounded) position bound on purpose: the persistent layer may
legitimately be empty (nothing
// flushed), so bounding on the input position would never be
satisfied.
@@ -268,6 +494,28 @@ public class IQv2HeadersStoreIntegrationTest {
return onlyResult == null ? null : onlyResult.getResult();
}
+ private List<ReadOnlyRecord<Integer, String>> rangeQuery(final
TimestampedRangeWithHeadersQuery<Integer, String> query) {
+ final StateQueryRequest<ReadOnlyRecordIterator<Integer, String>>
request =
+
inStore(STORE_NAME).withQuery(query).withPositionBound(PositionBound.at(inputPosition));
+ final StateQueryResult<ReadOnlyRecordIterator<Integer, String>> result
=
+ IntegrationTestUtils.iqv2WaitForResult(kafkaStreams, request);
+ final List<ReadOnlyRecord<Integer, String>> records = new
ArrayList<>();
+ try (ReadOnlyRecordIterator<Integer, String> iterator =
result.getOnlyPartitionResult().getResult()) {
+ while (iterator.hasNext()) {
+ records.add(iterator.next());
+ }
+ }
+ return records;
+ }
+
+ private static List<Integer> keys(final List<ReadOnlyRecord<Integer,
String>> records) {
+ return
records.stream().map(ReadOnlyRecord::key).collect(Collectors.toList());
+ }
+
+ private static List<String> values(final List<ReadOnlyRecord<Integer,
String>> records) {
+ return
records.stream().map(ReadOnlyRecord::value).collect(Collectors.toList());
+ }
+
private Properties props() {
final String safeTestName = safeUniqueTestName(testInfo);
final Properties streamsConfiguration = new Properties();
@@ -307,7 +555,7 @@ public class IQv2HeadersStoreIntegrationTest {
}
}
- private static class HeadersStoreWriterProcessor implements
Processor<Integer, String, Integer, String> {
+ private static class KeyValueHeadersStoreWriterProcessor implements
Processor<Integer, String, Integer, String> {
private ProcessorContext<Integer, String> context;
private TimestampedKeyValueStoreWithHeaders<Integer, String> store;
@@ -319,14 +567,18 @@ public class IQv2HeadersStoreIntegrationTest {
@Override
public void process(final Record<Integer, String> record) {
- store.put(
- record.key(),
- ValueTimestampHeaders.make(record.value(), record.timestamp(),
record.headers()));
+ if (record.value() == null) {
+ store.delete(record.key());
+ } else {
+ store.put(
+ record.key(),
+ ValueTimestampHeaders.make(record.value(),
record.timestamp(), record.headers()));
+ }
context.forward(record);
}
}
- private static class PlainStoreWriterProcessor implements
Processor<Integer, String, Integer, String> {
+ private static class KeyValuePlainStoreWriterProcessor implements
Processor<Integer, String, Integer, String> {
private ProcessorContext<Integer, String> context;
private TimestampedKeyValueStore<Integer, String> store;
diff --git
a/streams/src/main/java/org/apache/kafka/streams/query/TimestampedRangeWithHeadersQuery.java
b/streams/src/main/java/org/apache/kafka/streams/query/TimestampedRangeWithHeadersQuery.java
new file mode 100644
index 00000000000..65762c72f57
--- /dev/null
+++
b/streams/src/main/java/org/apache/kafka/streams/query/TimestampedRangeWithHeadersQuery.java
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.query;
+
+import org.apache.kafka.common.annotation.InterfaceAudience;
+import org.apache.kafka.common.annotation.InterfaceStability.Evolving;
+import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
+import org.apache.kafka.streams.state.ReadOnlyRecordIterator;
+import org.apache.kafka.streams.state.TimestampedKeyValueStoreWithHeaders;
+
+import java.util.Optional;
+
+/**
+ * Interactive query for issuing range queries and scans over a
+ * {@link TimestampedKeyValueStoreWithHeaders}, returning each record together
with its headers.
+ *
+ * <p>This is the headers-aware parallel of {@link TimestampedRangeQuery}: it
returns a
+ * {@link ReadOnlyRecordIterator} of {@link ReadOnlyRecord} elements, each
carrying the key, value,
+ * timestamp, and headers, whereas {@link TimestampedRangeQuery} returns a
+ * {@link org.apache.kafka.streams.state.KeyValueIterator} of
+ * {@link org.apache.kafka.streams.state.ValueAndTimestamp} (value and
timestamp only, no headers).
+ *
+ * <p>A range query retrieves a set of records, specified using an upper
and/or lower bound on the
+ * keys. A scan query (no bounds) retrieves all records contained in the
store. Keys' order is based
+ * on the serialized {@code byte[]} of the keys, not the 'logical' key order.
+ *
+ * <p>Headers are persisted and returned only when the store is backed by a
native headers store,
+ * i.e. built with a KIP-1271 {@code WithHeaders} byte-store supplier (e.g.
+ * {@code Stores.persistentTimestampedKeyValueStoreWithHeaders}). A {@code
WithHeaders} store built
+ * over a legacy (non-headers) supplier cannot persist headers, so the
store-served reads come back
+ * with empty {@code headers()}.
+ *
+ * <p>Against a plain store not built with the {@code WithHeaders} builder at
all, this query type is
+ * unsupported and fails with {@link FailureReason#UNKNOWN_QUERY_TYPE}.
+ *
+ * <p>Each element is a {@link ReadOnlyRecord}, whose timestamp is
non-negative. If the backing store
+ * does not persist timestamps -- for example a {@code WithHeaders} store
built over a plain
+ * {@link org.apache.kafka.streams.state.KeyValueStore} supplier, which
surfaces every entry with
+ * {@code NO_TIMESTAMP} ({@code -1}) -- that entry cannot be represented, so
advancing the returned
+ * {@link ReadOnlyRecordIterator} throws {@link
org.apache.kafka.streams.errors.StreamsException} at
+ * that entry. Back the store with {@code
Stores.persistentTimestampedKeyValueStoreWithHeaders(...)} to
+ * persist timestamps and headers, or use {@link TimestampedRangeQuery} if
headers are not needed.
+ *
+ * @param <K> Type of keys
+ * @param <V> Type of values
+ */
+@Evolving
[email protected]
+public final class TimestampedRangeWithHeadersQuery<K, V> implements
Query<ReadOnlyRecordIterator<K, V>> {
+
+ private final Optional<K> lower;
+ private final Optional<K> upper;
+ private final ResultOrder order;
+
+ private TimestampedRangeWithHeadersQuery(final Optional<K> lower, final
Optional<K> upper, final ResultOrder order) {
+ this.lower = lower;
+ this.upper = upper;
+ this.order = order;
+ }
+
+ /**
+ * Interactive range query using a lower and upper bound to filter the
keys returned.
+ * @param lower The key that specifies the lower bound of the range
+ * @param upper The key that specifies the upper bound of the range
+ * @param <K> The key type
+ * @param <V> The value type
+ */
+ public static <K, V> TimestampedRangeWithHeadersQuery<K, V>
withRange(final K lower, final K upper) {
+ return new
TimestampedRangeWithHeadersQuery<>(Optional.ofNullable(lower),
Optional.ofNullable(upper), ResultOrder.ANY);
+ }
+
+ /**
+ * Interactive range query using an upper bound to filter the keys
returned.
+ * @param upper The key that specifies the upper bound of the range
+ * @param <K> The key type
+ * @param <V> The value type
+ */
+ public static <K, V> TimestampedRangeWithHeadersQuery<K, V>
withUpperBound(final K upper) {
+ return new TimestampedRangeWithHeadersQuery<>(Optional.empty(),
Optional.of(upper), ResultOrder.ANY);
+ }
+
+ /**
+ * Interactive range query using a lower bound to filter the keys returned.
+ * @param lower The key that specifies the lower bound of the range
+ * @param <K> The key type
+ * @param <V> The value type
+ */
+ public static <K, V> TimestampedRangeWithHeadersQuery<K, V>
withLowerBound(final K lower) {
+ return new TimestampedRangeWithHeadersQuery<>(Optional.of(lower),
Optional.empty(), ResultOrder.ANY);
+ }
+
+ /**
+ * Interactive scan query that returns all records in the store.
+ * @param <K> The key type
+ * @param <V> The value type
+ */
+ public static <K, V> TimestampedRangeWithHeadersQuery<K, V> withNoBounds()
{
+ return new TimestampedRangeWithHeadersQuery<>(Optional.empty(),
Optional.empty(), ResultOrder.ANY);
+ }
+
+ /**
+ * Set the query to return the serialized {@code byte[]} of the keys in
descending order.
+ * Order is based on the serialized {@code byte[]} of the keys, not the
'logical' key order.
+ * @return a new query instance with the descending flag set.
+ */
+ public TimestampedRangeWithHeadersQuery<K, V> withDescendingKeys() {
+ return new TimestampedRangeWithHeadersQuery<>(this.lower, this.upper,
ResultOrder.DESCENDING);
+ }
+
+ /**
+ * Set the query to return the serialized {@code byte[]} of the keys in
ascending order.
+ * Order is based on the serialized {@code byte[]} of the keys, not the
'logical' key order.
+ * @return a new query instance with the ascending flag set.
+ */
+ public TimestampedRangeWithHeadersQuery<K, V> withAscendingKeys() {
+ return new TimestampedRangeWithHeadersQuery<>(this.lower, this.upper,
ResultOrder.ASCENDING);
+ }
+
+ /**
+ * The lower bound of the query, if specified.
+ */
+ public Optional<K> lowerBound() {
+ return lower;
+ }
+
+ /**
+ * The upper bound of the query, if specified.
+ */
+ public Optional<K> upperBound() {
+ return upper;
+ }
+
+ /**
+ * Determines if the serialized {@code byte[]} of the keys in ascending or
descending or unordered order.
+ * Order is based on the serialized {@code byte[]} of the keys, not the
'logical' key order.
+ * @return the order of the returned records based on the serialized
{@code byte[]} of the keys
+ * (can be unordered, ascending, or descending).
+ */
+ public ResultOrder resultOrder() {
+ return order;
+ }
+}
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
index 98b6a024b22..3d10e5569cc 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeaders.java
@@ -26,6 +26,7 @@ import org.apache.kafka.common.utils.Bytes;
import org.apache.kafka.common.utils.Time;
import org.apache.kafka.streams.KeyValue;
import org.apache.kafka.streams.errors.ProcessorStateException;
+import org.apache.kafka.streams.errors.StreamsException;
import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
import org.apache.kafka.streams.processor.api.Record;
import org.apache.kafka.streams.processor.internals.ProcessorRecordContext;
@@ -41,10 +42,12 @@ import org.apache.kafka.streams.query.ResultOrder;
import org.apache.kafka.streams.query.TimestampedKeyQuery;
import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
import org.apache.kafka.streams.query.TimestampedRangeQuery;
+import org.apache.kafka.streams.query.TimestampedRangeWithHeadersQuery;
import org.apache.kafka.streams.query.internals.InternalQueryResultUtil;
import org.apache.kafka.streams.state.KeyValueIterator;
import org.apache.kafka.streams.state.KeyValueStore;
import org.apache.kafka.streams.state.ReadOnlyKeyValueStore;
+import org.apache.kafka.streams.state.ReadOnlyRecordIterator;
import org.apache.kafka.streams.state.TimestampedKeyValueStoreWithHeaders;
import org.apache.kafka.streams.state.ValueAndTimestamp;
import org.apache.kafka.streams.state.ValueTimestampHeaders;
@@ -54,6 +57,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.function.Function;
import static org.apache.kafka.common.utils.Utils.mkEntry;
@@ -106,6 +110,10 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
mkEntry(
TimestampedRangeQuery.class,
(query, positionBound, config, store) ->
runTimestampedRangeQuery(query, positionBound, config)
+ ),
+ mkEntry(
+ TimestampedRangeWithHeadersQuery.class,
+ (query, positionBound, config, store) ->
runTimestampedRangeWithHeadersQuery(query, positionBound, config)
)
);
@@ -451,6 +459,22 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
return result;
}
+ private RangeQuery<Bytes, byte[]> rawRangeQuery(final Optional<K>
lowerBound,
+ final Optional<K>
upperBound,
+ final ResultOrder order) {
+ RangeQuery<Bytes, byte[]> rawRangeQuery = RangeQuery.withRange(
+ serializeKey(lowerBound.orElse(null), internalContext.headers()),
+ serializeKey(upperBound.orElse(null), internalContext.headers())
+ );
+ if (order.equals(ResultOrder.DESCENDING)) {
+ rawRangeQuery = rawRangeQuery.withDescendingKeys();
+ }
+ if (order.equals(ResultOrder.ASCENDING)) {
+ rawRangeQuery = rawRangeQuery.withAscendingKeys();
+ }
+ return rawRangeQuery;
+ }
+
@SuppressWarnings("unchecked")
private <R> QueryResult<R> runRangeQuery(
final Query<R> query,
@@ -460,18 +484,8 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
final QueryResult<R> result;
final RangeQuery<K, V> typedQuery = (RangeQuery<K, V>) query;
- RangeQuery<Bytes, byte[]> rawRangeQuery;
- final ResultOrder order = typedQuery.resultOrder();
- rawRangeQuery = RangeQuery.withRange(
- serializeKey(typedQuery.getLowerBound().orElse(null),
internalContext.headers()),
- serializeKey(typedQuery.getUpperBound().orElse(null),
internalContext.headers())
- );
- if (order.equals(ResultOrder.DESCENDING)) {
- rawRangeQuery = rawRangeQuery.withDescendingKeys();
- }
- if (order.equals(ResultOrder.ASCENDING)) {
- rawRangeQuery = rawRangeQuery.withAscendingKeys();
- }
+ final RangeQuery<Bytes, byte[]> rawRangeQuery =
+ rawRangeQuery(typedQuery.getLowerBound(),
typedQuery.getUpperBound(), typedQuery.resultOrder());
final QueryResult<KeyValueIterator<Bytes, byte[]>> rawResult =
wrapped().query(rawRangeQuery, positionBound, config);
if (rawResult.isSuccess()) {
@@ -505,18 +519,8 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
final QueryResult<R> result;
final TimestampedRangeQuery<K, V> typedQuery =
(TimestampedRangeQuery<K, V>) query;
- RangeQuery<Bytes, byte[]> rawRangeQuery;
- final ResultOrder order = typedQuery.resultOrder();
- rawRangeQuery = RangeQuery.withRange(
- serializeKey(typedQuery.lowerBound().orElse(null),
internalContext.headers()),
- serializeKey(typedQuery.upperBound().orElse(null),
internalContext.headers())
- );
- if (order.equals(ResultOrder.DESCENDING)) {
- rawRangeQuery = rawRangeQuery.withDescendingKeys();
- }
- if (order.equals(ResultOrder.ASCENDING)) {
- rawRangeQuery = rawRangeQuery.withAscendingKeys();
- }
+ 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()) {
@@ -542,6 +546,40 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
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;
+ }
+ return result;
+ }
+
@Override
public <PS extends Serializer<P>, P> KeyValueIterator<K,
ValueTimestampHeaders<V>> prefixScan(
final P prefix, final PS prefixKeySerializer
@@ -615,13 +653,49 @@ public class
MeteredTimestampedKeyValueStoreWithHeaders<K, V>
return new
MeteredTimestampedKeyValueStoreWithHeadersIterator(store.reverseAll(),
allSensor);
}
- @SuppressWarnings("unchecked")
- private class MeteredTimestampedKeyValueStoreWithHeadersQueryIterator
implements KeyValueIterator<K, V>, MeteredIterator {
+ /**
+ * Shared scaffolding for the metered iterators below: tracks {@code
num-open-iterators},
+ * {@code oldest-iterator-open-since-ms}, and per-operation iterator
duration, and delegates
+ * closing the wrapped raw iterator. Subclasses only need to implement the
deserializing
+ * {@code next()}/{@code hasNext()} (and, where applicable, {@code
peekNextKey()}).
+ */
+ private abstract class AbstractMeteredIterator implements MeteredIterator {
- private final KeyValueIterator<Bytes, byte[]> iter;
+ final KeyValueIterator<Bytes, byte[]> iter;
private final Sensor sensor;
private final long startNs;
private final long startTimestampMs;
+
+ AbstractMeteredIterator(final KeyValueIterator<Bytes, byte[]> iter,
final Sensor sensor) {
+ this.iter = iter;
+ this.sensor = sensor;
+ this.startNs = time.nanoseconds();
+ this.startTimestampMs = time.milliseconds();
+ numOpenIterators.increment();
+ openIterators.add(this);
+ }
+
+ @Override
+ public long startTimestamp() {
+ return startTimestampMs;
+ }
+
+ public void close() {
+ try {
+ iter.close();
+ } finally {
+ final long duration = time.nanoseconds() - startNs;
+ sensor.record(duration);
+ iteratorDurationSensor.record(duration);
+ numOpenIterators.decrement();
+ openIterators.remove(this);
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private class MeteredTimestampedKeyValueStoreWithHeadersQueryIterator
extends AbstractMeteredIterator implements KeyValueIterator<K, V> {
+
private final Function<byte[], ValueTimestampHeaders<V>>
valueTimestampHeadersDeserializer;
private final boolean returnPlainValue;
@@ -633,18 +707,9 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
final Function<byte[], ValueTimestampHeaders<V>>
valueTimestampHeadersDeserializer,
final boolean returnPlainValue
) {
- this.iter = iter;
- this.sensor = sensor;
+ super(iter, sensor);
this.valueTimestampHeadersDeserializer =
valueTimestampHeadersDeserializer;
- this.startNs = time.nanoseconds();
- this.startTimestampMs = time.milliseconds();
this.returnPlainValue = returnPlainValue;
- openIterators.add(this);
- }
-
- @Override
- public long startTimestamp() {
- return startTimestampMs;
}
@Override
@@ -675,18 +740,6 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
}
}
- @Override
- public void close() {
- try {
- iter.close();
- } finally {
- final long duration = time.nanoseconds() - startNs;
- sensor.record(duration);
- iteratorDurationSensor.record(duration);
- openIterators.remove(this);
- }
- }
-
@Override
public K peekNextKey() {
if (cachedNext == null) {
@@ -696,28 +749,79 @@ public class
MeteredTimestampedKeyValueStoreWithHeaders<K, V>
}
}
- private class MeteredTimestampedKeyValueStoreWithHeadersIterator
implements KeyValueIterator<K, ValueTimestampHeaders<V>>, MeteredIterator {
- private final KeyValueIterator<Bytes, byte[]> iter;
- private final Sensor sensor;
- private final long startNs;
- private final long startTimestampMs;
- private KeyValue<K, ValueTimestampHeaders<V>> cachedNext;
+ /**
+ * 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.
+ *
+ * <p>That throw does not close the iterator: a caller that catches it and
abandons the iterator
+ * leaks the underlying raw iterator and permanently inflates {@code
num-open-iterators}. Callers
+ * must close this iterator in a {@code finally} block or a
try-with-resources statement, even when
+ * {@code next()} throws.
+ */
+ private class
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator
+ extends AbstractMeteredIterator implements ReadOnlyRecordIterator<K,
V> {
- private MeteredTimestampedKeyValueStoreWithHeadersIterator(
+ private final Function<byte[], ValueTimestampHeaders<V>>
valueTimestampHeadersDeserializer;
+
+ private
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator(
final KeyValueIterator<Bytes, byte[]> iter,
- final Sensor sensor
+ final Sensor sensor,
+ final Function<byte[], ValueTimestampHeaders<V>>
valueTimestampHeadersDeserializer
) {
- this.iter = iter;
- this.sensor = sensor;
- this.startNs = time.nanoseconds();
- this.startTimestampMs = time.milliseconds();
- numOpenIterators.increment();
- openIterators.add(this);
+ super(iter, sensor);
+ this.valueTimestampHeadersDeserializer =
valueTimestampHeadersDeserializer;
}
@Override
- public long startTimestamp() {
- return startTimestampMs;
+ 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;
+ }
+ }
+
+ private class MeteredTimestampedKeyValueStoreWithHeadersIterator
+ extends AbstractMeteredIterator implements KeyValueIterator<K,
ValueTimestampHeaders<V>> {
+
+ private KeyValue<K, ValueTimestampHeaders<V>> cachedNext;
+
+ private MeteredTimestampedKeyValueStoreWithHeadersIterator(
+ final KeyValueIterator<Bytes, byte[]> iter,
+ final Sensor sensor
+ ) {
+ super(iter, sensor);
}
@Override
@@ -739,19 +843,6 @@ public class MeteredTimestampedKeyValueStoreWithHeaders<K,
V>
return KeyValue.pair(key, valueTimestampHeaders);
}
- @Override
- public void close() {
- try {
- iter.close();
- } finally {
- final long duration = time.nanoseconds() - startNs;
- sensor.record(duration);
- iteratorDurationSensor.record(duration);
- numOpenIterators.decrement();
- openIterators.remove(this);
- }
- }
-
@Override
public K peekNextKey() {
if (cachedNext == null) {
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
index d0ad49acfcd..931889c58d2 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeaders.java
@@ -18,11 +18,6 @@
package org.apache.kafka.streams.state.internals;
import org.apache.kafka.streams.errors.ProcessorStateException;
-import org.apache.kafka.streams.query.KeyQuery;
-import org.apache.kafka.streams.query.PositionBound;
-import org.apache.kafka.streams.query.Query;
-import org.apache.kafka.streams.query.QueryConfig;
-import org.apache.kafka.streams.query.QueryResult;
import org.apache.kafka.streams.state.HeadersBytesStore;
import org.apache.kafka.streams.state.internals.metrics.RocksDBMetricsRecorder;
@@ -195,33 +190,4 @@ public class RocksDBTimestampedStoreWithHeaders extends
RocksDBStore implements
}
}
- @SuppressWarnings("SynchronizeOnNonFinalField")
- @Override
- public <R> QueryResult<R> query(final Query<R> query,
- final PositionBound positionBound,
- final QueryConfig config) {
- // KIP-1356 (point query only): the headers-aware
TimestampedKeyWithHeadersQuery forwards a raw
- // KeyQuery to this native store, so enable KeyQuery via the inherited
RocksDBStore handling.
- // Every other query type (e.g. RangeQuery) stays UNKNOWN_QUERY_TYPE
here, unchanged from before;
- // those are enabled by their own KIP-1356 follow-ups.
- if (query instanceof KeyQuery) {
- return super.query(query, positionBound, config);
- }
-
- final long start = config.isCollectExecutionInfo() ? System.nanoTime()
: -1L;
- final QueryResult<R> result;
-
- synchronized (position) {
- result = QueryResult.forUnknownQueryType(query, this);
-
- if (config.isCollectExecutionInfo()) {
- result.addExecutionInfo(
- "Handled in " + this.getClass() + " in " +
(System.nanoTime() - start) + "ns"
- );
- }
- result.setPosition(position.copy());
- }
- return result;
- }
-
}
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
index 568a11a7226..7a45afae1e8 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/MeteredTimestampedKeyValueStoreWithHeadersTest.java
@@ -36,14 +36,20 @@ import org.apache.kafka.streams.processor.TaskId;
import org.apache.kafka.streams.processor.internals.InternalProcessorContext;
import org.apache.kafka.streams.processor.internals.ProcessorStateManager;
import org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl;
+import org.apache.kafka.streams.query.FailureReason;
import org.apache.kafka.streams.query.KeyQuery;
import org.apache.kafka.streams.query.PositionBound;
import org.apache.kafka.streams.query.Query;
import org.apache.kafka.streams.query.QueryConfig;
import org.apache.kafka.streams.query.QueryResult;
+import org.apache.kafka.streams.query.RangeQuery;
+import org.apache.kafka.streams.query.ResultOrder;
import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
+import org.apache.kafka.streams.query.TimestampedRangeQuery;
+import org.apache.kafka.streams.query.TimestampedRangeWithHeadersQuery;
import org.apache.kafka.streams.state.KeyValueIterator;
import org.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.ReadOnlyRecordIterator;
import org.apache.kafka.streams.state.ValueTimestampHeaders;
import org.apache.kafka.test.KeyValueIteratorStub;
@@ -55,6 +61,8 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
+import java.io.Closeable;
+import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -65,6 +73,7 @@ import static org.apache.kafka.common.utils.Utils.mkMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
@@ -89,7 +98,9 @@ public class MeteredTimestampedKeyValueStoreWithHeadersTest {
private static final RecordHeaders HEADERS = makeHeaders();
private static final ValueTimestampHeaders<String> VALUE_TIMESTAMP_HEADERS
=
ValueTimestampHeaders.make("value", 97L, HEADERS);
- private static final byte[] VALUE_TIMESTAMP_HEADERS_BYTES =
serializeValueTimestampHeaders();
+ private static final byte[] VALUE_TIMESTAMP_HEADERS_BYTES =
serializeValueTimestampHeaders(VALUE_TIMESTAMP_HEADERS);
+ private static final byte[]
NEGATIVE_TIMESTAMP_VALUE_TIMESTAMP_HEADERS_BYTES =
+ serializeValueTimestampHeaders(ValueTimestampHeaders.make("value",
-1L, HEADERS));
private final String threadId = Thread.currentThread().getName();
private final TaskId taskId = new TaskId(0, 0, "My-Topology");
@Mock
@@ -302,6 +313,166 @@ public class
MeteredTimestampedKeyValueStoreWithHeadersTest {
assertTrue((Double) metric.metricValue() > 0);
}
+ @Test
+ public void
shouldPropagateLowerBoundAndDescendingOrderForTimestampedRangeWithHeadersQuery()
{
+ setUp();
+ init();
+ final RangeQuery<?, ?> rawQuery = forwardedRawRangeQuery(
+ TimestampedRangeWithHeadersQuery.<String,
String>withLowerBound("a").withDescendingKeys());
+ assertEquals(ResultOrder.DESCENDING, rawQuery.resultOrder());
+ assertEquals(Bytes.wrap("a".getBytes()),
rawQuery.getLowerBound().get());
+ assertFalse(rawQuery.getUpperBound().isPresent());
+ }
+
+ @Test
+ public void
shouldPropagateUpperBoundAndAscendingOrderForTimestampedRangeWithHeadersQuery()
{
+ setUp();
+ init();
+ final RangeQuery<?, ?> rawQuery = forwardedRawRangeQuery(
+ TimestampedRangeWithHeadersQuery.<String,
String>withUpperBound("z").withAscendingKeys());
+ assertEquals(ResultOrder.ASCENDING, rawQuery.resultOrder());
+ assertFalse(rawQuery.getLowerBound().isPresent());
+ assertEquals(Bytes.wrap("z".getBytes()),
rawQuery.getUpperBound().get());
+ }
+
+ @Test
+ public void
shouldPropagateNoBoundsAndAnyOrderForTimestampedRangeWithHeadersQuery() {
+ setUp();
+ init();
+ final RangeQuery<?, ?> rawQuery = forwardedRawRangeQuery(
+ TimestampedRangeWithHeadersQuery.withNoBounds());
+ assertEquals(ResultOrder.ANY, rawQuery.resultOrder());
+ assertFalse(rawQuery.getLowerBound().isPresent());
+ assertFalse(rawQuery.getUpperBound().isPresent());
+ }
+
+ @Test
+ public void
shouldPropagateBothBoundsAndAnyOrderForTimestampedRangeWithHeadersQuery() {
+ setUp();
+ init();
+ final RangeQuery<?, ?> rawQuery = forwardedRawRangeQuery(
+ TimestampedRangeWithHeadersQuery.withRange("a", "z"));
+ assertEquals(ResultOrder.ANY, rawQuery.resultOrder());
+ assertEquals(Bytes.wrap("a".getBytes()),
rawQuery.getLowerBound().get());
+ assertEquals(Bytes.wrap("z".getBytes()),
rawQuery.getUpperBound().get());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void shouldPropagateFailureForTimestampedRangeWithHeadersQuery() {
+ setUp();
+ init();
+ final QueryResult<KeyValueIterator<Bytes, byte[]>> rawFailure =
+ QueryResult.forFailure(FailureReason.NOT_UP_TO_BOUND, "not yet
caught up");
+ when(inner.query(any(), any(PositionBound.class),
any(QueryConfig.class))).thenReturn((QueryResult) rawFailure);
+
+ final QueryResult<ReadOnlyRecordIterator<String, String>> result =
metered.query(
+ TimestampedRangeWithHeadersQuery.withNoBounds(),
PositionBound.unbounded(), new QueryConfig(false));
+
+ assertFalse(result.isSuccess());
+ assertEquals(FailureReason.NOT_UP_TO_BOUND, result.getFailureReason());
+ }
+
+ // The num-open-iterators gauge is tracked by every metered iterator this
store returns from query().
+ // Each query type is served by a different iterator class, so each needs
its own 0->1->0 guard:
+ // - TimestampedRangeWithHeadersQuery ->
MeteredTimestampedKeyValueStoreWithHeadersReadOnlyRecordIterator
+ // - RangeQuery / TimestampedRangeQuery ->
MeteredTimestampedKeyValueStoreWithHeadersQueryIterator
+ // (the all()/range() KeyValueIterator path is covered separately by
shouldTrackOpenIteratorsMetric).
+
+ @Test
+ public void
shouldTrackOpenIteratorsMetricForTimestampedRangeWithHeadersQuery() throws
IOException {
+
assertQueryTracksOpenIteratorsMetric(TimestampedRangeWithHeadersQuery.withNoBounds());
+ }
+
+ @Test
+ public void shouldTrackOpenIteratorsMetricForRangeQuery() throws
IOException {
+ assertQueryTracksOpenIteratorsMetric(RangeQuery.withNoBounds());
+ }
+
+ @Test
+ public void shouldTrackOpenIteratorsMetricForTimestampedRangeQuery()
throws IOException {
+
assertQueryTracksOpenIteratorsMetric(TimestampedRangeQuery.withNoBounds());
+ }
+
+ @SuppressWarnings("unchecked")
+ private void assertQueryTracksOpenIteratorsMetric(final Query<?> query)
throws IOException {
+ setUp();
+ when(inner.query(any(), any(PositionBound.class),
any(QueryConfig.class)))
+ .thenReturn((QueryResult)
QueryResult.forResult(KeyValueIterators.emptyIterator()));
+ init();
+
+ final KafkaMetric openIteratorsMetric = metric("num-open-iterators");
+ assertEquals(0L, (Long) openIteratorsMetric.metricValue());
+
+ // The result iterator type differs per query (ReadOnlyRecordIterator
vs KeyValueIterator); both are
+ // Closeable, which is all this gauge check needs.
+ try (Closeable iterator = (Closeable) metered.query(
+ query, PositionBound.unbounded(), new
QueryConfig(false)).getResult()) {
+ assertEquals(1L, (Long) openIteratorsMetric.metricValue());
+ }
+
+ assertEquals(0L, (Long) openIteratorsMetric.metricValue());
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void
shouldDecrementOpenIteratorsTwiceWhenClosedTwiceForTimestampedRangeWithHeadersQuery()
{
+ // Documents current behavior: close() is not idempotent, so a caller
that (incorrectly) closes
+ // twice double-decrements the gauge. This is a misuse path, not a
supported usage pattern.
+ setUp();
+ when(inner.query(any(), any(PositionBound.class),
any(QueryConfig.class)))
+ .thenReturn((QueryResult)
QueryResult.forResult(KeyValueIterators.emptyIterator()));
+ init();
+
+ final KafkaMetric openIteratorsMetric = metric("num-open-iterators");
+ final QueryResult<ReadOnlyRecordIterator<String, String>> result =
metered.query(
+ TimestampedRangeWithHeadersQuery.withNoBounds(),
PositionBound.unbounded(), new QueryConfig(false));
+ final ReadOnlyRecordIterator<String, String> iterator =
result.getResult();
+
+ iterator.close();
+ assertEquals(0L, (Long) openIteratorsMetric.metricValue());
+ iterator.close();
+ assertEquals(-1L, (Long) openIteratorsMetric.metricValue(), "A second
close() double-decrements the gauge");
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ public void
shouldLeaveIteratorOpenWhenNextThrowsAndNotClosedForTimestampedRangeWithHeadersQuery()
{
+ // Documents current behavior: an entry with a negative stored
timestamp makes next() throw
+ // instead of returning, and that throw does not close the iterator. A
caller that catches the
+ // exception without closing (in violation of the class's contract)
leaks the open-iterator count.
+ setUp();
+ final KeyValue<Bytes, byte[]> negativeTimestampEntry =
+ KeyValue.pair(KEY_BYTES,
NEGATIVE_TIMESTAMP_VALUE_TIMESTAMP_HEADERS_BYTES);
+ when(inner.query(any(), any(PositionBound.class),
any(QueryConfig.class)))
+ .thenReturn((QueryResult) QueryResult.forResult(
+ new
KeyValueIteratorStub<>(List.of(negativeTimestampEntry).iterator())));
+ init();
+
+ final KafkaMetric openIteratorsMetric = metric("num-open-iterators");
+ final QueryResult<ReadOnlyRecordIterator<String, String>> result =
metered.query(
+ TimestampedRangeWithHeadersQuery.withNoBounds(),
PositionBound.unbounded(), new QueryConfig(false));
+ final ReadOnlyRecordIterator<String, String> iterator =
result.getResult();
+
+ assertThrows(StreamsException.class, iterator::next);
+ assertEquals(1L, (Long) openIteratorsMetric.metricValue(),
+ "An uncaught/unclosed iterator after next() throws must remain
open");
+
+ iterator.close();
+ assertEquals(0L, (Long) openIteratorsMetric.metricValue());
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private RangeQuery<?, ?> forwardedRawRangeQuery(final Query<?> query) {
+ when(inner.query(any(), any(PositionBound.class),
any(QueryConfig.class)))
+ .thenReturn((QueryResult) QueryResult.forResult(
+ new KeyValueIteratorStub<>(List.<KeyValue<Bytes,
byte[]>>of().iterator())));
+ metered.query(query, PositionBound.unbounded(), new
QueryConfig(false));
+ final ArgumentCaptor<RangeQuery> captor =
ArgumentCaptor.forClass(RangeQuery.class);
+ verify(inner).query(captor.capture(), any(PositionBound.class),
any(QueryConfig.class));
+ return captor.getValue();
+ }
+
@Test
public void shouldGetAllFromInnerStoreAndRecordAllMetric() {
setUp();
@@ -495,9 +666,9 @@ public class MeteredTimestampedKeyValueStoreWithHeadersTest
{
return headers;
}
- private static byte[] serializeValueTimestampHeaders() {
+ private static byte[] serializeValueTimestampHeaders(final
ValueTimestampHeaders<String> valueTimestampHeaders) {
final ValueTimestampHeadersSerializer<String> serializer = new
ValueTimestampHeadersSerializer<>(Serdes.String().serializer());
- return serializer.serialize("topic", VALUE_TIMESTAMP_HEADERS);
+ return serializer.serialize("topic", valueTimestampHeaders);
}
@SuppressWarnings("unchecked")
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
index be048ece610..c1b1259d7f8 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreWithHeadersTest.java
@@ -1030,23 +1030,30 @@ public class RocksDBTimestampedStoreWithHeadersTest
extends RocksDBStoreTest {
}
@Test
- public void shouldReturnUnknownQueryTypeForRangeQuery() {
+ public void shouldHandleRangeQuery() {
// Initialize the store
rocksDBStore.init(context, rocksDBStore);
- // KIP-1356 (point query only): this store enables KeyQuery but leaves
RangeQuery (and every
- // other non-KeyQuery type) as UNKNOWN_QUERY_TYPE, unchanged from
before. RangeQuery support on
- // the native header store is deferred to the range KIP-1356 follow-up.
+ // Store raw (serialized ValueTimestampHeaders) bytes for a key.
+ final Bytes key = new Bytes("test-key".getBytes());
+ final byte[] storedBytes = "headers+timestamp+value".getBytes();
+ rocksDBStore.put(key, storedBytes);
+
+ // KIP-1356: the range follow-up enables RangeQuery on the native
header store via the inherited
+ // RocksDBStore handling (returning the raw stored header-format
bytes; the metered store does
+ // the header-aware deserialization), matching the adapter build path.
final RangeQuery<Bytes, byte[]> query = RangeQuery.withNoBounds();
final QueryResult<KeyValueIterator<Bytes, byte[]>> result =
rocksDBStore.query(query, PositionBound.unbounded(), new
QueryConfig(false));
- assertFalse(result.isSuccess(), "Expected RangeQuery to be
unsupported");
- assertEquals(
- FailureReason.UNKNOWN_QUERY_TYPE,
- result.getFailureReason(),
- "Expected UNKNOWN_QUERY_TYPE failure reason"
- );
+ assertTrue(result.isSuccess(), "Expected RangeQuery to succeed");
+ try (KeyValueIterator<Bytes, byte[]> iterator = result.getResult()) {
+ assertTrue(iterator.hasNext(), "Expected the stored key in the
range result");
+ final KeyValue<Bytes, byte[]> keyValue = iterator.next();
+ assertEquals(key, keyValue.key);
+ assertArrayEquals(storedBytes, keyValue.value, "Expected the raw
stored bytes to be returned");
+ assertFalse(iterator.hasNext());
+ }
assertNotNull(result.getPosition(), "Expected position to be set");
}
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
index cccc1174e53..1863e977b23 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/TimestampedKeyValueStoreBuilderWithHeadersTest.java
@@ -24,6 +24,8 @@ import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.common.utils.Bytes;
import org.apache.kafka.common.utils.MockTime;
import org.apache.kafka.common.utils.internals.LogContext;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.errors.StreamsException;
import org.apache.kafka.streams.processor.StateStore;
import org.apache.kafka.streams.processor.api.ReadOnlyRecord;
import org.apache.kafka.streams.processor.internals.MockStreamsMetrics;
@@ -37,8 +39,12 @@ import org.apache.kafka.streams.query.QueryResult;
import org.apache.kafka.streams.query.RangeQuery;
import org.apache.kafka.streams.query.TimestampedKeyQuery;
import org.apache.kafka.streams.query.TimestampedKeyWithHeadersQuery;
+import org.apache.kafka.streams.query.TimestampedRangeQuery;
+import org.apache.kafka.streams.query.TimestampedRangeWithHeadersQuery;
import org.apache.kafka.streams.state.KeyValueBytesStoreSupplier;
+import org.apache.kafka.streams.state.KeyValueIterator;
import org.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.ReadOnlyRecordIterator;
import org.apache.kafka.streams.state.TimestampedKeyValueStoreWithHeaders;
import org.apache.kafka.streams.state.ValueAndTimestamp;
import org.apache.kafka.streams.state.ValueTimestampHeaders;
@@ -56,7 +62,9 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
import java.util.Collections;
+import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -481,24 +489,6 @@ public class
TimestampedKeyValueStoreBuilderWithHeadersTest {
}
}
- @ParameterizedTest
- @ValueSource(booleans = {true, false})
- public void shouldReturnUnknownQueryTypeForRangeQueryOnHeadersStore(final
boolean cachingEnabled) {
- // KIP-1356 (point query only): the native header store enables
KeyQuery but not RangeQuery, so a
- // RangeQuery reports UNKNOWN_QUERY_TYPE (caching on or off).
RangeQuery support on the native
- // build is deferred to the range follow-up.
- final TimestampedKeyValueStoreWithHeaders<String, String> store =
buildAndInitStore(StoreType.NATIVE, cachingEnabled);
- try {
- final QueryResult<?> result =
- store.query(RangeQuery.withNoBounds(),
PositionBound.unbounded(), new QueryConfig(false));
- assertFalse(result.isSuccess(), "Expected RangeQuery to be
unsupported on the native header store");
- assertEquals(FailureReason.UNKNOWN_QUERY_TYPE,
result.getFailureReason());
- assertNotNull(result.getPosition(), "Expected position to be set");
- } finally {
- store.close();
- }
- }
-
@ParameterizedTest
@CsvSource({"NATIVE", "ADAPTER", "IN_MEMORY"})
public void shouldReturnEmptyPositionInitially(final StoreType storeType) {
@@ -615,4 +605,229 @@ public class
TimestampedKeyValueStoreBuilderWithHeadersTest {
adapterStore.close();
}
}
+
+ @Test
+ public void shouldHandleRangeQuery() {
+ // KIP-1356: the native header store now serves RangeQuery
(header-stripped results). A range
+ // query reads the underlying store directly (never the cache), so
caching on/off makes no
+ // difference here; use a store-served (caching-disabled) build,
matching the other range tests.
+ final TimestampedKeyValueStoreWithHeaders<String, String> store =
buildAndInitStore(StoreType.NATIVE, false);
+ try {
+ store.put("k", ValueTimestampHeaders.make("v", 123L,
headersWith("h", "x")));
+
+ 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()) {
+ assertTrue(iterator.hasNext(), "Expected the stored record to
be returned");
+ final KeyValue<String, String> keyValue = iterator.next();
+ assertEquals("k", keyValue.key);
+ assertEquals("v", keyValue.value);
+ assertFalse(iterator.hasNext());
+ }
+ 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()) {
+ final StreamsException exception =
assertThrows(StreamsException.class, iterator::next,
+ "An entry with ts=-1 cannot be represented as a
ReadOnlyRecord");
+ assertTrue(exception.getMessage().contains("timestamp"),
+ "Expected exception message to mention the timestamp: " +
exception.getMessage());
+ }
+ } 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()) {
+ final StreamsException exception =
assertThrows(StreamsException.class, iterator::next);
+ assertTrue(exception.getMessage().contains("timestamp"),
+ "Expected exception message to mention the timestamp: " +
exception.getMessage());
+ }
+ } 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() {
+ // Build-path parity for the existing (header-stripped) range query
types. Range queries read the
+ // underlying store (never the cache), so use a store-served
(caching-disabled) build. Multiple
+ // keys pin that ordering (not just per-key equality) matches across
both build paths.
+ final TimestampedKeyValueStoreWithHeaders<String, String> nativeStore
= buildAndInitStore(StoreType.NATIVE, false);
+ final TimestampedKeyValueStoreWithHeaders<String, String> adapterStore
= buildAndInitStore(StoreType.ADAPTER, false);
+ try {
+ // Insert in a different order per store: a range result is sorted
by key bytes, not insertion
+ // order, so if either build path were (incorrectly)
insertion-order-dependent, the two stores
+ // would diverge here instead of accidentally matching.
+ for (final String key : List.of("a", "b", "c")) {
+ nativeStore.put(key, ValueTimestampHeaders.make("v-" + key,
123L, headersWith("h", "x")));
+ }
+ for (final String key : List.of("c", "b", "a")) {
+ adapterStore.put(key, ValueTimestampHeaders.make("v-" + key,
123L, headersWith("h", "x")));
+ }
+
+ final List<KeyValue<String, String>> nativeRange =
+ drain(nativeStore.query(RangeQuery.<String,
String>withNoBounds(), PositionBound.unbounded(), new
QueryConfig(false)).getResult());
+ final List<KeyValue<String, String>> adapterRange =
+ drain(adapterStore.query(RangeQuery.<String,
String>withNoBounds(), PositionBound.unbounded(), new
QueryConfig(false)).getResult());
+ assertEquals(3, nativeRange.size(), "Expected all three keys to be
returned");
+ assertEquals(nativeRange, adapterRange,
+ "RangeQuery results (including order) should be identical
across native and adapter build paths");
+
+ final List<KeyValue<String, ValueAndTimestamp<String>>>
nativeTimestampedRange =
+ drain(nativeStore.query(TimestampedRangeQuery.<String,
String>withNoBounds(), PositionBound.unbounded(), new
QueryConfig(false)).getResult());
+ final List<KeyValue<String, ValueAndTimestamp<String>>>
adapterTimestampedRange =
+ drain(adapterStore.query(TimestampedRangeQuery.<String,
String>withNoBounds(), PositionBound.unbounded(), new
QueryConfig(false)).getResult());
+ assertEquals(3, nativeTimestampedRange.size(), "Expected all three
keys to be returned");
+ assertEquals(nativeTimestampedRange, adapterTimestampedRange,
+ "TimestampedRangeQuery results (including order) should be
identical across native and adapter build paths");
+ } finally {
+ nativeStore.close();
+ adapterStore.close();
+ }
+ }
+
+ private static <V> List<KeyValue<String, V>> drain(final
KeyValueIterator<String, V> iterator) {
+ final List<KeyValue<String, V>> out = new ArrayList<>();
+ try (iterator) {
+ while (iterator.hasNext()) {
+ out.add(iterator.next());
+ }
+ }
+ return out;
+ }
}