KAFKA-20704: IQv2 TimestampedRangeWithHeadersQuery for headers-aware key-value stores (KIP-1356)#22770
KAFKA-20704: IQv2 TimestampedRangeWithHeadersQuery for headers-aware key-value stores (KIP-1356)#22770Jess668 wants to merge 12 commits into
Conversation
a3efe3d to
8bd56d9
Compare
- Add the headers-aware range query TimestampedRangeWithHeadersQuery, returning a ReadOnlyRecordIterator of ReadOnlyRecord (key/value/timestamp/headers). - MeteredTimestampedKeyValueStoreWithHeaders handles it by forwarding a raw RangeQuery to the wrapped store and adapting each entry into a frozen-header ReadOnlyRecord; an entry whose stored timestamp is negative cannot be represented as a ReadOnlyRecord, so advancing the iterator throws StreamsException. - Also drop the query() override on RocksDBTimestampedStoreWithHeaders so the native header store serves the basic IQv2 queries via the inherited RocksDBStore handling.
- TimestampedKeyValueStoreBuilderWithHeadersTest: end-to-end build-path coverage (native/in-memory return headers; adapter returns empty headers; plain-legacy and negative timestamps throw while iterating), execution-info collection, and native/adapter parity. - MeteredTimestampedKeyValueStoreWithHeadersTest: unit coverage of the metered handler forwarding the bounds and result order onto the raw RangeQuery. - RocksDBTimestampedStoreWithHeadersTest: the native store now serves RangeQuery. - TimestampedRangeWithHeadersQueryIntegrationTest: IQv2 integration coverage.
…ntion - Query with PositionBound.at(INPUT_POSITION) + iqv2WaitForResult
1cef606 to
dcc29c9
Compare
aliehsaeedii
left a comment
There was a problem hiding this comment.
Thanks @Jess668. The PR looks good, but I still spent some time catching a few final issues to get the code ready for merge.
| this.startNs = time.nanoseconds(); | ||
| this.startTimestampMs = time.milliseconds(); | ||
| this.returnPlainValue = returnPlainValue; | ||
| numOpenIterators.increment(); |
There was a problem hiding this 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.
| final Headers headers = valueTimestampHeaders.headers(); | ||
| final K key = deserializeKey(keyValue.key.get(), headers); | ||
| if (valueTimestampHeaders.timestamp() < 0) { | ||
| throw new StreamsException( |
There was a problem hiding this 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.
| final long duration = time.nanoseconds() - startNs; | ||
| sensor.record(duration); | ||
| iteratorDurationSensor.record(duration); | ||
| numOpenIterators.decrement(); |
There was a problem hiding this 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.
| * 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 |
There was a problem hiding this 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.
| result = (QueryResult<R>) typedQueryResult; | ||
| } else { | ||
| // the generic type doesn't matter, since failed queries have no result set. | ||
| result = (QueryResult<R>) rawResult; |
There was a problem hiding this 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).
|
|
||
| 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, |
There was a problem hiding this 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.
| IntegrationTestUtils.startApplicationAndWaitUntilRunning(kafkaStreams); | ||
| @Test | ||
| public void shouldHandleTimestampedRangeWithHeadersQuery() throws Exception { | ||
| // Caching disabled: a range query reads the underlying store directly (it never consults the |
There was a problem hiding this 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).
|
|
||
| // 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)); |
There was a problem hiding this 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).
|
|
||
| // withUpperBound(2) -> keys 1, 2. | ||
| final List<ReadOnlyRecord<Integer, String>> upperBounded = | ||
| rangeQuery(TimestampedRangeWithHeadersQuery.withUpperBound(2)); |
There was a problem hiding this comment.
No range test asserts an empty result (a bound matching no keys). A quick empty-range case would cover the no-match path.
| PlainStoreWriterProcessor::new); | ||
| } | ||
|
|
||
| private void startStreamsWithPlainSupplierStore() throws Exception { |
There was a problem hiding this 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.
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 theReadOnlyRecordIterator<K, V>— aCloseableiterator that yieldsReadOnlyRecord<K, V>elements directly.Dependencies
This work is stacked on, and should be reviewed/merged after:
ReadOnlyRecordPR#22677 — already intrunk.ReadOnlyRecordIteratorPR#22729 — already intrunk.TimestampedKeyWithHeadersQueryPR#22666 — already intrunk.All three have landed; this branch has been rebased onto
trunkand contains only the range commits.Summary
TimestampedRangeWithHeadersQuery<K, V> implements Query<ReadOnlyRecordIterator<K, V>>(
@Evolving/@InterfaceAudience.Public). MirrorsTimestampedRangeQuery: static factorieswithRange,withUpperBound,withLowerBound,withNoBounds; order builderswithDescendingKeys/withAscendingKeys;accessors
lowerBound(),upperBound(),resultOrder(). Same bound/order semantics asTimestampedRangeQuery.MeteredTimestampedKeyValueStoreWithHeadersregisters aTimestampedRangeWithHeadersQueryhandler and returns a
ReadOnlyRecordIteratorwhose elements are built as immutableReadOnlyRecords (headers frozen viasetReadOnly()). The bounds+order → rawRangeQueryconstruction shared by the three range handlers is factored into a
rawRangeQuery(...)helper.RocksDBTimestampedStoreWithHeadersdrops itsscope-to-
KeyQuery-onlyquery()override, falling back to the inheritedRocksDBStorehandling,which serves
RangeQuery. Previously the native header store returnedUNKNOWN_QUERY_TYPEforrange queries.
num-open-iteratorsmetric fix: the IQ range iterators (QueryIterator, used byRangeQuery/TimestampedRangeQuery, and the newReadOnlyRecordIteratorfor this query)registered in
openIteratorsbut never updatednumOpenIterators, so they were invisible to thenum-open-iteratorsgauge. Both now increment on open / decrement on close, matching the baseMeteredKeyValueStoreIterator.Testing
MeteredTimestampedKeyValueStoreWithHeadersTest: bounds (as serialized keys) andascending/descending order are propagated to the underlying raw
RangeQuery.TimestampedKeyValueStoreBuilderWithHeadersTest:RangeQueryon thenative store;
TimestampedRangeWithHeadersQueryreturns exact headers on a header-persistingstore, 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.
RocksDBTimestampedStoreWithHeadersTest:RangeQueryis now handled (wasUNKNOWN_QUERY_TYPE).IQv2HeadersStoreIntegrationTest:TimestampedRangeWithHeadersQueryover arunning topology returns each record's correct key/value/timestamp/headers across order and bound
variants;
UNKNOWN_QUERY_TYPEagainst a non-headers store; failure against a plain supplier.Reviewers: Alieh Saeedi asaeedi@confluent.io