DUE TO SPAM, SIGN-UP IS DISABLED. Goto Selfserve wiki signup and request an account.
This page is meant as a template for writing a KIP. To create a KIP choose Tools->Copy on this page and modify with your content and replace the heading with the next KIP number and a description of your issue. Replace anything in italics with your own description.
Status
Current state: Accepted
Discussion thread: here
JIRA: KAFKA-20682
Please keep the discussion on the mailing list rather than commenting on the wiki (wiki discussions get unwieldy fast).
Motivation
KIP-1271 added the ability to persist record headers inside Kafka Streams state stores. When a store is built with one of the new WithHeaders suppliers/builders, the record's Headers are stored alongside the value, surfaced internally through the wrapper types ValueTimestampHeaders<V> (timestamped key/value and window stores) and AggregationWithHeaders<AGG> (session stores).
However, that infrastructure deliberately does not expose headers through Interactive Queries (IQv2): there is no query type whose result carries headers, so even against a header-aware store the stored headers cannot be read back.
Moreover, support for the existing query types against header stores is itself incomplete and depends on how the store was built. Against an adapter-built header store, queries such as KeyQuery, TimestampedKeyQuery, RangeQuery, TimestampedRangeQuery, WindowKeyQuery, and WindowRangeQuery return only value/timestamp and do not expose headers; against a natively-built header store the same queries fail with UNKNOWN_QUERY_TYPE.
The result is an asymmetry: a user can configure a store to store headers, but has no way to read those headers back via IQv2.
This KIP closes that gap by introducing new IQv2 query types whose results carry headers and by making the existing query types behave consistently against header stores regardless of how the store was built (see Proposed Changes).
Public Interfaces
ReadOnlyRecord
The new query types return their results as a new read-only interface, org.apache.kafka.streams.processor.api.ReadOnlyRecord<K, V>, which exposes a record's key(), value(), timestamp(), and headers() and nothing else. The existing PAPI Record<K, V> is made to implement ReadOnlyRecord<K, V>.
package org.apache.kafka.streams.processor.api;
public interface ReadOnlyRecord<K, V> {
public K key();
public V value();
public long timestamp();
public Headers headers();
}
package org.apache.kafka.streams.processor.api;
public class Record<K, V> implements ReadOnlyRecord<K, V> {
// unchanged
}
Four new @Evolving, final query classes are added to package org.apache.kafka.streams.query. Each parallels an existing query type and uses the no-get accessor convention (key(), timeFrom(), timeTo(), lowerBound(), upperBound()) throughout — following the newer TimestampedKeyQuery/TimestampedRangeQuery style rather than the older getX() accessors on WindowKeyQuery/WindowRangeQuery, so the new types are internally consistent.
For the iterator queries the result is a ReadOnlyRecordIterator — a closeable iterator that yields ReadOnlyRecord elements directly.
ReadOnlyRecordIterator
package org.apache.kafka.streams.state;
public interface ReadOnlyRecordIterator<K, V> extends Iterator<ReadOnlyRecord<K, V>>, Closeable {
@Override
void close();
}
TimestampedKeyWithHeadersQuery
Parallel of TimestampedKeyQuery.
package org.apache.kafka.streams.query;
@Evolving
public final class TimestampedKeyWithHeadersQuery<K, V> implements Query<ReadOnlyRecord<K, V>> {
public static <K, V> TimestampedKeyWithHeadersQuery<K, V> withKey(final K key);
// Skip the cache during query evaluation, forwarding the query to the underlying store.
public TimestampedKeyWithHeadersQuery<K, V> skipCache();
public K key();
public boolean isSkipCache();
}
TimestampedRangeWithHeadersQuery
Parallel of TimestampedRangeQuery.
package org.apache.kafka.streams.query;
@Evolving
public final class TimestampedRangeWithHeadersQuery<K, V> implements Query<ReadOnlyRecordIterator<K, V>> {
public static <K, V> TimestampedRangeWithHeadersQuery<K, V> withRange(final K lower, final K upper);
public static <K, V> TimestampedRangeWithHeadersQuery<K, V> withUpperBound(final K upper);
public static <K, V> TimestampedRangeWithHeadersQuery<K, V> withLowerBound(final K lower);
public static <K, V> TimestampedRangeWithHeadersQuery<K, V> withNoBounds();
public TimestampedRangeWithHeadersQuery<K, V> withDescendingKeys();
public TimestampedRangeWithHeadersQuery<K, V> withAscendingKeys();
public Optional<K> lowerBound();
public Optional<K> upperBound();
public ResultOrder resultOrder();
}
TimestampedWindowKeyWithHeadersQuery
Parallel of WindowKeyQuery.
Like its counterpart WindowKeyQuery, this type overrides toString(); it is omitted from the signature above as it carries no API contract.
package org.apache.kafka.streams.query;
@Evolving
public final class TimestampedWindowKeyWithHeadersQuery<K, V> implements Query<ReadOnlyRecordIterator<Windowed<K>, V>> {
public static <K, V> TimestampedWindowKeyWithHeadersQuery<K, V> withKeyAndWindowStartRange(final K key, final Instant timeFrom, final Instant timeTo);
public K key();
public Optional<Instant> timeFrom();
public Optional<Instant> timeTo();
}
TimestampedWindowRangeWithHeadersQuery
Parallel of WindowRangeQuery.
Like its counterpart WindowRangeQuery, this type overrides toString(); it is omitted from the signature above as it carries no API contract.
package org.apache.kafka.streams.query;
@Evolving
public final class TimestampedWindowRangeWithHeadersQuery<K, V> implements Query<ReadOnlyRecordIterator<Windowed<K>, V>> {
public static <K, V> TimestampedWindowRangeWithHeadersQuery<K, V> withWindowStartRange(final Instant timeFrom, final Instant timeTo);
public static <K, V> TimestampedWindowRangeWithHeadersQuery<K, V> withKey(final K key);
public Optional<K> key();
public Optional<Instant> timeFrom();
public Optional<Instant> timeTo();
}
Proposed Changes
New
ReadOnlyRecordinterface +Recordimplements it. As above; the only PAPI change is additive.New query classes returning
ReadOnlyRecord-based results.Handler wiring + wrapper→
ReadOnlyRecordconversion in the metered header stores. Each metered header store handles its new query types by converting the store's internal wrapper into aReadOnlyRecord(constructed as aRecord):MeteredTimestampedKeyValueStoreWithHeadershandlesTimestampedKeyWithHeadersQueryandTimestampedRangeWithHeadersQuery:ValueTimestampHeaders<V> vth→new Record<>(key, vth.value(), vth.timestamp(), vth.headers()).MeteredTimestampedWindowStoreWithHeadershandlesTimestampedWindowKeyWithHeadersQueryand thewithWindowStartRangeform ofTimestampedWindowRangeWithHeadersQuery:vth→new Record<>(new Windowed<>(key, window), vth.value(), vth.timestamp(), vth.headers()).timestamp()is the stored record event-time; the window start/end live in theWindowed<K>key.MeteredSessionStoreWithHeadershandles thewithKeyform ofTimestampedWindowRangeWithHeadersQuery:AggregationWithHeaders<AGG> awh→new Record<>(new Windowed<>(key, sessionWindow), awh.aggregation(), sessionWindow.end(), awh.headers()). For sessions the record timestamp is the session-window end (which is why KIP-1271 stores it only in the key, not the value), sotimestamp()is filled fromwindow.end().
Native header bytes stores now implement
query(...)(closes a build-path-dependent gap). Today the existing (non-header) query types succeed or fail depending on how the header store was built:- On the adapter build path, the metered store wraps a plain/timestamped byte store with a
ToHeadersStoreAdapterthat forwards basic IQv2 queries to a normalRocksDBStore(which handles them viaStoreQueryUtils) — so the queries succeed. - On the native build path, the metered store wraps a native header bytes store (
RocksDBTimestampedStoreWithHeaders,RocksDBTimestampedWindowStoreWithHeaders,RocksDBTimeOrderedWindowStoreWithHeaders,RocksDBSessionStoreWithHeaders,RocksDBTimeOrderedSessionStoreWithHeaders), each of which overridesquery(...)to returnUNKNOWN_QUERY_TYPEfor every query — so the queries fail.
This KIP removes that override and makes the native header bytes stores handle the basic IQv2 queries (returning their stored header-format bytes via
StoreQueryUtils.handleBasicQueries, with header-aware (de)serialization). As a result, the existing query types (KeyQuery,TimestampedKeyQuery,RangeQuery,TimestampedRangeQueryon the KV store;WindowKeyQuery,WindowRangeQuery.withWindowStartRangeon the window store;WindowRangeQuery.withKeyon the session store) behave identically on both build paths, returning header-stripped results, with IQv2PositionBoundand position semantics preserved byStoreQueryUtils. No new public classes are introduced for this part.- On the adapter build path, the metered store wraps a plain/timestamped byte store with a
- Behaviour against non-headers stores is unchanged. Submitting one of the new
WithHeadersquery types to a store that was not built with aWithHeaderssupplier produces the standard IQv2 "unknown query type"QueryResultfailure. - Window time-range constraint. As with the existing window queries, the window
WithHeadersqueries require a closed window start range (bothtimeFromandtimeTopresent).
Usage example
// Store built with a WithHeaders supplier (KIP-1271):
// Stores.timestampedKeyValueStoreWithHeadersBuilder(
// Stores.persistentTimestampedKeyValueStoreWithHeaders("store"), keySerde, valueSerde)
TimestampedKeyWithHeadersQuery<String, Long> query = TimestampedKeyWithHeadersQuery.withKey("key");
StateQueryRequest<ReadOnlyRecord<String, Long>> request = StateQueryRequest.inStore("store").withQuery(query);
StateQueryResult<ReadOnlyRecord<String, Long>> result = kafkaStreams.query(request);
ReadOnlyRecord<String, Long> rec = result.getOnlyPartitionResult().getResult();
Long value = rec.value();
long timestamp = rec.timestamp();
Headers headers = rec.headers(); // now available via IQv2
Compatibility, Deprecation, and Migration Plan
- Two new interfaces (
ReadOnlyRecordandReadOnlyRecordIterator) and four new@Evolvingquery classes. No existing query types, result types, serdes, or store byte formats are changed. Recordchange is additive.Recordnow implementsReadOnlyRecord(it already had all four accessors); this is source- and binary-compatible, so existing PAPI code is unaffected. All of these packages live in the samekafka-streamsmodule so the placement imposes no new dependency in either case.- Behaviour change for native header stores. Existing query types that previously returned
UNKNOWN_QUERY_TYPEagainst natively built header stores now succeed, returning header-stripped results — matching the behaviour that adapter-built header stores already had. This removes a build-path-dependent inconsistency; no caller that worked before changes behaviour. - The new query types' behavior depends on how the store was built:
WithHeadersbuilder + a header-aware (native) supplier → queries succeed and return the stored headers.WithHeadersbuilder + a non-header supplier (the builder wraps it with a*ToHeadersStoreAdapter, or an in-memory marker) → queries still succeed, butheaders()is always empty: the underlying store cannot persist headers, so reads come back with a zero-header value (HeadersBytesStore.convertToHeaderFormatprepends a header count of 0).- A plain, non-
WithHeadersbuilder → the new query types are unsupported and fail cleanly withUNKNOWN_QUERY_TYPE.
Because the new types are @Evolving, their API may be refined in a later minor release without a formal deprecation cycle if needed.
Test Plan
New cases are added to the IQv2 integration test suite:
shouldHandleTimestampedKeyWithHeadersQuery— asserts the returnedReadOnlyRecordcarries the value, timestamp, key, and the exact headers written.shouldHandleTimestampedRangeWithHeadersQuery— asserts eachReadOnlyRecordcarries the correct headers, including ascending/descending ordering and the bound variants.shouldHandleTimestampedWindowKeyWithHeadersQuery— asserts windowedReadOnlyRecords carry headers across the requested window start range, with the window in theWindowed<K>key.shouldHandleTimestampedWindowRangeWithHeadersQuery— covers both forms:withWindowStartRangeagainst the window store andwithKeyagainst the session store; asserts headers are carried and that the sessionReadOnlyRecord.timestamp()equals the session-window end.- Build-path parity (native vs adapter). For each header store, run the existing query types against both a natively built and an adapter-built store and assert identical, header-stripped results — locking in the native-path fix so it cannot regress to
UNKNOWN_QUERY_TYPE. - Empty-headers cases — for each query type, assert a record written with no headers round-trips as a
ReadOnlyRecordwhoseheaders()is empty (an emptyRecordHeaders, nevernull). - Tombstone cases — a tombstone is a
nullvalue and deletes the key, so a tombstoned key is observably identical to an absent key. Assert that a tombstoned (or never-written) key returns anullresult for the point query and is omitted from iterator results. - A unit test asserting
Recordis assignable toReadOnlyRecordand exposes the samekey/value/timestamp/headers. - Negative tests asserting the new query types fail cleanly (unknown-query-type failure) against non-headers stores, and that each store rejects the wrong
WindowRangeWithHeadersQueryform. - Non-header supplier into a
WithHeadersbuilder — assert the new query types still succeed but return emptyheaders(), even for records written with headers, since the underlying (non-header) store cannot persist them.