This is an automated email from the ASF dual-hosted git repository.
bbejeck 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 360b381cfb7 KAFKA-20760: Stage position updates in
InMemoryKeyValueStore transaction buffer (#22743)
360b381cfb7 is described below
commit 360b381cfb743d348e84641ebeeaffc8ca99b066
Author: Nick Telford <[email protected]>
AuthorDate: Fri Jul 3 01:48:25 2026 +0100
KAFKA-20760: Stage position updates in InMemoryKeyValueStore transaction
buffer (#22743)
`InMemoryKeyValueStore`, when transactional, did not correctly track its
`Position` across the transaction boundary, which broke
interactive-query position consistency in two ways.
**Staging.** `put`/`putAll` applied position updates directly to the
store's committed `Position` instead of holding them pending alongside
the staged writes, so the position advanced before the corresponding
data was committed. Position updates are now accumulated in a
`pendingPosition` on `InMemoryTransactionBuffer` during staging and
merged into the committed position only in `commitStagedWrites`.
`discardPendingBatch` (previously a no-op) resets `pendingPosition` so
aborted batches leave no trace. Under READ_UNCOMMITTED, `query` merges
the pending deltas so staged writes remain visible, mirroring
`RocksDBStore`.
**Changelog consistency vector.** `getPosition()` returned only the
committed position. Because `ChangeLoggingKeyValueBytesStore` stamps
each changelog record's consistency vector with `getPosition()` at put
time, a transactional store's changelog records carried a stale (empty)
vector, and the position could not be rebuilt when the store was
restored from its changelog on restart. `getPosition()` now returns the
uncommitted position (committed merged with the buffer's pending
deltas), matching the behaviour `RocksDBStore` already had.
Adds a unit test covering the uncommitted-position behaviour. End-to-end
restart coverage is added separately via the parameterized
`PositionRestartIntegrationTest`.
Part of a series of fixes for interactive-query position consistency
with transactional state stores.
Reviewers: Bill Bejeck <[email protected]>
---
.../state/internals/InMemoryKeyValueStore.java | 45 +++++++++++++++++-----
.../state/internals/InMemoryTransactionBuffer.java | 33 +++++++++++++++-
.../state/internals/InMemoryKeyValueStoreTest.java | 38 ++++++++++++++++++
3 files changed, 106 insertions(+), 10 deletions(-)
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStore.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStore.java
index 0a1f94ef321..fcf570a101a 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStore.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStore.java
@@ -125,6 +125,16 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
@Override
public Position getPosition() {
+ // Mirror RocksDBStore#getPosition: report the uncommitted position
(committed + staged) so the
+ // changelog consistency vector, which ChangeLogging*BytesStore writes
at put() time via
+ // getPosition(), reflects the input position of the staged write.
Otherwise a transactional
+ // store's changelog records carry the stale committed position and
the position cannot be
+ // rebuilt when the store is restored from its changelog.
+ if (transactionBuffer != null) {
+ synchronized (position) {
+ return
position.copy().merge(transactionBuffer.pendingPosition());
+ }
+ }
return position;
}
@@ -133,14 +143,26 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
final PositionBound positionBound,
final QueryConfig config) {
- return StoreQueryUtils.handleBasicQueries(
- query,
- positionBound,
- config,
- this,
- position,
- context
- );
+ synchronized (position) {
+ // Mirror RocksDBStore#query: under READ_UNCOMMITTED, expose the
writes staged in the
+ // transaction buffer since the last commit by merging the
buffer's pending position
+ // deltas into a copy of the committed position. READ_COMMITTED
(and the
+ // non-transactional store) query the committed position directly.
+ final Position queryPosition;
+ if (transactionBuffer != null && config.getIsolationLevel() ==
IsolationLevel.READ_UNCOMMITTED) {
+ queryPosition =
position.copy().merge(transactionBuffer.pendingPosition());
+ } else {
+ queryPosition = position;
+ }
+ return StoreQueryUtils.handleBasicQueries(
+ query,
+ positionBound,
+ config,
+ this,
+ queryPosition,
+ context
+ );
+ }
}
@Override
@@ -167,6 +189,7 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
public synchronized void put(final Bytes key, final byte[] value) {
if (transactionBuffer != null) {
transactionBuffer.stage(key, value);
+ transactionBuffer.updatePosition(context);
return;
}
putInternal(key, value);
@@ -199,6 +222,7 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
if (transactionBuffer != null) {
for (final KeyValue<Bytes, byte[]> entry : entries) {
transactionBuffer.stage(entry.key, entry.value);
+ transactionBuffer.updatePosition(context);
}
return;
}
@@ -365,7 +389,10 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
void commitStagedWrites() {
if (transactionBuffer != null) {
- transactionBuffer.commit();
+ synchronized (position) {
+ transactionBuffer.mergePendingPositionInto(position);
+ transactionBuffer.commit();
+ }
}
}
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTransactionBuffer.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTransactionBuffer.java
index 26a8ef8f61c..9de1f9c62a1 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTransactionBuffer.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryTransactionBuffer.java
@@ -18,6 +18,8 @@ package org.apache.kafka.streams.state.internals;
import org.apache.kafka.common.utils.Bytes;
import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.processor.StateStoreContext;
+import org.apache.kafka.streams.query.Position;
import java.util.Map;
import java.util.NavigableMap;
@@ -42,6 +44,7 @@ import java.util.TreeMap;
class InMemoryTransactionBuffer extends AbstractTransactionBuffer<Bytes> {
private final NavigableMap<Bytes, byte[]> baseMap;
+ private Position pendingPosition = Position.emptyPosition();
InMemoryTransactionBuffer(final NavigableMap<Bytes, byte[]> baseMap) {
this.baseMap = baseMap;
@@ -127,7 +130,35 @@ class InMemoryTransactionBuffer extends
AbstractTransactionBuffer<Bytes> {
@Override
void discardPendingBatch() {
- // no-op — no backend batch to discard
+ pendingPosition = Position.emptyPosition();
+ }
+
+ void updatePosition(final StateStoreContext stateStoreContext) {
+ snapshotLock.writeLock().lock();
+ try {
+ StoreQueryUtils.updatePosition(pendingPosition, stateStoreContext);
+ } finally {
+ snapshotLock.writeLock().unlock();
+ }
+ }
+
+ Position pendingPosition() {
+ snapshotLock.readLock().lock();
+ try {
+ return pendingPosition.copy();
+ } finally {
+ snapshotLock.readLock().unlock();
+ }
+ }
+
+ void mergePendingPositionInto(final Position committed) {
+ snapshotLock.writeLock().lock();
+ try {
+ committed.merge(pendingPosition);
+ pendingPosition = Position.emptyPosition();
+ } finally {
+ snapshotLock.writeLock().unlock();
+ }
}
/**
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStoreTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStoreTest.java
index 894d88674fc..93a84a748a4 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStoreTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryKeyValueStoreTest.java
@@ -600,6 +600,44 @@ public class InMemoryKeyValueStoreTest extends
AbstractKeyValueStoreTest {
}
}
+ @Test
+ public void shouldReportUncommittedPositionForTransactionalStore() {
+ final Properties props = StreamsTestUtils.getStreamsConfig();
+ props.setProperty(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
StreamsConfig.EXACTLY_ONCE_V2);
+ props.setProperty(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG,
"true");
+ final InternalMockProcessorContext<Bytes, byte[]> ctx = new
InternalMockProcessorContext<>(
+ TestUtils.tempDirectory(),
+ new Serdes.BytesSerde(),
+ new Serdes.ByteArraySerde(),
+ new StreamsConfig(props)
+ );
+ final InMemoryKeyValueStore store = new
InMemoryKeyValueStore("txn-pos-store");
+ store.init(ctx, store);
+ try {
+ ctx.setRecordContext(new ProcessorRecordContext(0, 1, 0, "topic",
new RecordHeaders()));
+ store.put(bytesKey("key1"), bytesValue("value1"));
+
+ final Position expected = Position.fromMap(mkMap(mkEntry("topic",
mkMap(mkEntry(0, 1L)))));
+
+ // READ_UNCOMMITTED query should expose the staged position before
commit
+ final org.apache.kafka.streams.query.QueryResult<?> uncommitted =
store.query(
+ org.apache.kafka.streams.query.RangeQuery.withNoBounds(),
+ org.apache.kafka.streams.query.PositionBound.unbounded(),
+ new org.apache.kafka.streams.query.QueryConfig(false));
+ assertEquals(expected, uncommitted.getPosition(),
"READ_UNCOMMITTED query position");
+
+ // getPosition() reports the uncommitted (committed + staged)
position, mirroring
+ // RocksDBStore, so the changelog consistency vector reflects the
staged write.
+ assertEquals(expected, store.getPosition(), "getPosition before
commit (uncommitted)");
+
+ // after commit, committed position populated
+ store.commit(Map.of());
+ assertEquals(expected, store.getPosition(), "getPosition after
commit");
+ } finally {
+ store.close();
+ }
+ }
+
private InMemoryKeyValueStore openTransactionalStore() {
final Properties props = StreamsTestUtils.getStreamsConfig();
props.setProperty(StreamsConfig.PROCESSING_GUARANTEE_CONFIG,
StreamsConfig.EXACTLY_ONCE_V2);