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 a884fadfa4d KAFKA-20499: Add isolation-level reads to in-memory stores
(#22681)
a884fadfa4d is described below
commit a884fadfa4d1fe8b1c0e2f14a46ccf9491fd1f70
Author: Nick Telford <[email protected]>
AuthorDate: Tue Jun 30 23:26:51 2026 +0100
KAFKA-20499: Add isolation-level reads to in-memory stores (#22681)
Part of the KIP-892 interactive-query isolation-level series. With
transactional state stores enabled, the in-memory key-value, window, and
session stores buffer uncommitted writes in a transaction buffer, and
their reads consulted that buffer unconditionally. An interactive query
therefore had no way to request only committed data — a `READ_COMMITTED`
query would observe writes still staged in the open transaction.
This overrides `readOnly(IsolationLevel)` on each of the three in-memory
stores to return a view that consults the transaction buffer under
`READ_UNCOMMITTED` and bypasses it under `READ_COMMITTED`, reading the
base map directly. For the window and session stores the buffer dispatch
in each read path is extracted into private helpers parameterised by the
buffer (collapsing the forward/backward pairs into shared helpers), so
the view can opt in or out without duplicating the iteration logic. The
key-value view synchronises on the store's monitor so concurrent IQ
reads observe a consistent snapshot relative to processor-thread writes.
Semantic tests across the three store families assert that
`READ_UNCOMMITTED` observes staged writes (and deletes) while
`READ_COMMITTED` sees only the last committed snapshot.
This branched off the now-merged in-memory transactional-store and
IQ-isolation-framework work and now applies directly to trunk.
Reviewers: Bill Bejeck <[email protected]>
---
.../state/internals/AbstractTransactionBuffer.java | 52 ++++-
.../state/internals/InMemoryKeyValueStore.java | 141 +++++++++---
.../state/internals/InMemorySessionStore.java | 245 ++++++++++++++-------
.../InMemorySessionTransactionBuffer.java | 36 ++-
.../state/internals/InMemoryTransactionBuffer.java | 21 ++
.../state/internals/InMemoryWindowStore.java | 174 +++++++++++----
.../internals/InMemoryWindowTransactionBuffer.java | 32 ++-
.../ChangeLoggingKeyValueBytesStoreTest.java | 21 +-
.../state/internals/InMemoryKeyValueStoreTest.java | 176 +++++++++++++++
.../state/internals/InMemorySessionStoreTest.java | 150 +++++++++++++
.../state/internals/InMemoryWindowStoreTest.java | 171 ++++++++++++++
11 files changed, 1056 insertions(+), 163 deletions(-)
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractTransactionBuffer.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractTransactionBuffer.java
index 865fe9fb07e..95881cb8022 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractTransactionBuffer.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractTransactionBuffer.java
@@ -16,6 +16,8 @@
*/
package org.apache.kafka.streams.state.internals;
+import org.apache.kafka.common.IsolationLevel;
+
import java.util.NavigableMap;
import java.util.Optional;
import java.util.TreeMap;
@@ -124,14 +126,22 @@ abstract class AbstractTransactionBuffer<K extends
Comparable<K>> implements Tra
@Override
public ManagedKeyValueIterator<K, byte[]> all(final boolean forward) {
+ return all(forward, IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ /**
+ * As {@link #all(boolean)}, but under {@link
IsolationLevel#READ_COMMITTED} the staging layer is
+ * excluded and only the committed base store is iterated.
+ */
+ ManagedKeyValueIterator<K, byte[]> all(final boolean forward, final
IsolationLevel isolationLevel) {
if (Thread.currentThread() == ownerThread) {
// Eager copy of the staging layer (lock-free for the sole
mutator) so a later owner
// stage cannot invalidate this iterator; the base store is
iterated live.
- final NavigableMap<K, Optional<byte[]>> stagingSnapshot = new
TreeMap<>(pendingWrites);
+ final NavigableMap<K, Optional<byte[]>> stagingSnapshot =
stagingSnapshot(null, null, true, isolationLevel);
final ManagedKeyValueIterator<K, byte[]> baseIter =
newBaseIterator(null, null, forward, true);
return new StagedMergeIterator<>(stagingSnapshot, baseIter,
forward);
}
- return snapshotScan(null, null, forward, true);
+ return snapshotScan(null, null, forward, true, isolationLevel);
}
/**
@@ -139,12 +149,21 @@ abstract class AbstractTransactionBuffer<K extends
Comparable<K>> implements Tra
*/
@Override
public ManagedKeyValueIterator<K, byte[]> range(final K from, final K to,
final boolean forward, final boolean toInclusive) {
+ return range(from, to, forward, toInclusive,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ /**
+ * As {@link #range(Object, Object, boolean, boolean)}, but under {@link
IsolationLevel#READ_COMMITTED}
+ * the staging layer is excluded and only the committed base store is
iterated.
+ */
+ ManagedKeyValueIterator<K, byte[]> range(final K from, final K to, final
boolean forward, final boolean toInclusive,
+ final IsolationLevel
isolationLevel) {
if (Thread.currentThread() == ownerThread) {
- final NavigableMap<K, Optional<byte[]>> stagingSnapshot = new
TreeMap<>(boundStaging(from, to, toInclusive));
+ final NavigableMap<K, Optional<byte[]>> stagingSnapshot =
stagingSnapshot(from, to, toInclusive, isolationLevel);
final ManagedKeyValueIterator<K, byte[]> baseIter =
newBaseIterator(from, to, forward, toInclusive);
return new StagedMergeIterator<>(stagingSnapshot, baseIter,
forward);
}
- return snapshotScan(from, to, forward, toInclusive);
+ return snapshotScan(from, to, forward, toInclusive, isolationLevel);
}
@Override
@@ -195,9 +214,20 @@ abstract class AbstractTransactionBuffer<K extends
Comparable<K>> implements Tra
*/
ManagedKeyValueIterator<K, byte[]> snapshotScan(final K from, final K to,
final boolean forward,
final boolean toInclusive) {
+ return snapshotScan(from, to, forward, toInclusive,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ /**
+ * As {@link #snapshotScan(Object, Object, boolean, boolean)}, but under
+ * {@link IsolationLevel#READ_COMMITTED} only the committed base store is
snapshotted, excluding the
+ * staging layer.
+ */
+ ManagedKeyValueIterator<K, byte[]> snapshotScan(final K from, final K to,
+ final boolean forward,
final boolean toInclusive,
+ final IsolationLevel
isolationLevel) {
snapshotLock.readLock().lock();
try {
- final NavigableMap<K, Optional<byte[]>> stagingSnapshot = new
TreeMap<>(boundStaging(from, to, toInclusive));
+ final NavigableMap<K, Optional<byte[]>> stagingSnapshot =
stagingSnapshot(from, to, toInclusive, isolationLevel);
final ManagedKeyValueIterator<K, byte[]> baseIter =
newBaseSnapshotIterator(from, to, forward, toInclusive);
return new StagedMergeIterator<>(stagingSnapshot, baseIter,
forward);
} finally {
@@ -205,6 +235,18 @@ abstract class AbstractTransactionBuffer<K extends
Comparable<K>> implements Tra
}
}
+ /**
+ * An eager copy of the bounded staging range, so a later owner {@code
stage} cannot invalidate an
+ * open iterator. Under {@link IsolationLevel#READ_COMMITTED} the copy is
empty, which makes the
+ * {@link StagedMergeIterator} degenerate to a committed-only (base) view.
+ */
+ private NavigableMap<K, Optional<byte[]>> stagingSnapshot(final K from,
final K to, final boolean toInclusive,
+ final
IsolationLevel isolationLevel) {
+ return isolationLevel == IsolationLevel.READ_UNCOMMITTED
+ ? new TreeMap<>(boundStaging(from, to, toInclusive))
+ : new TreeMap<>();
+ }
+
/**
* Constructs a view over the transaction buffer's read buffer consisting
of only the keys in the given range.
* If {@code from} or {@code to} are {@code null}, the range will be
open-ended; if they are both {@code null}, the
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 ae020cf0c08..0a1f94ef321 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
@@ -17,6 +17,7 @@
package org.apache.kafka.streams.state.internals;
import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.IsolationLevel;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.serialization.Serializer;
import org.apache.kafka.common.utils.Bytes;
@@ -34,6 +35,7 @@ import org.apache.kafka.streams.query.QueryConfig;
import org.apache.kafka.streams.query.QueryResult;
import org.apache.kafka.streams.state.KeyValueIterator;
import org.apache.kafka.streams.state.KeyValueStore;
+import org.apache.kafka.streams.state.ReadOnlyKeyValueStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -43,6 +45,7 @@ import java.util.List;
import java.util.Map;
import java.util.NavigableMap;
import java.util.NoSuchElementException;
+import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
@@ -141,14 +144,23 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
}
@Override
- public synchronized byte[] get(final Bytes key) {
+ public byte[] get(final Bytes key) {
+ return get(key, IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private byte[] get(final Bytes key, final IsolationLevel isolationLevel) {
if (transactionBuffer != null) {
- final java.util.Optional<byte[]> staged =
transactionBuffer.get(key);
- if (staged != null) {
- return staged.orElse(null);
+ if (isolationLevel == IsolationLevel.READ_UNCOMMITTED) {
+ final java.util.Optional<byte[]> staged =
transactionBuffer.get(key);
+ if (staged != null) {
+ return staged.orElse(null);
+ }
}
+ return transactionBuffer.getCommitted(key);
+ }
+ synchronized (this) {
+ return map.get(key);
}
- return map.get(key);
}
@Override
@@ -196,15 +208,21 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
}
@Override
- public synchronized <PS extends Serializer<P>, P> KeyValueIterator<Bytes,
byte[]> prefixScan(final P prefix, final PS prefixKeySerializer) {
+ public <PS extends Serializer<P>, P> KeyValueIterator<Bytes, byte[]>
prefixScan(final P prefix, final PS prefixKeySerializer) {
+ return prefixScan(prefix, prefixKeySerializer,
IsolationLevel.READ_UNCOMMITTED);
+ }
+ private <PS extends Serializer<P>, P> KeyValueIterator<Bytes, byte[]>
prefixScan(final P prefix, final PS prefixKeySerializer,
+
final IsolationLevel isolationLevel) {
final Bytes from = Bytes.wrap(prefixKeySerializer.serialize(null,
prefix));
final Bytes to = ByteUtils.increment(from);
if (transactionBuffer != null) {
- return transactionBuffer.range(from, to, true, false);
+ return transactionBuffer.range(from, to, true, false,
isolationLevel);
+ }
+ synchronized (this) {
+ return new InMemoryKeyValueIterator(map.subMap(from, true, to,
false).keySet(), true);
}
- return new InMemoryKeyValueIterator(map.subMap(from, true, to,
false).keySet(), true);
}
@Override
@@ -218,33 +236,37 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
}
@Override
- public synchronized KeyValueIterator<Bytes, byte[]> range(final Bytes
from, final Bytes to) {
- return range(from, to, true);
+ public KeyValueIterator<Bytes, byte[]> range(final Bytes from, final Bytes
to) {
+ return range(from, to, true, IsolationLevel.READ_UNCOMMITTED);
}
@Override
- public synchronized KeyValueIterator<Bytes, byte[]> reverseRange(final
Bytes from, final Bytes to) {
- return range(from, to, false);
+ public KeyValueIterator<Bytes, byte[]> reverseRange(final Bytes from,
final Bytes to) {
+ return range(from, to, false, IsolationLevel.READ_UNCOMMITTED);
}
- private KeyValueIterator<Bytes, byte[]> range(final Bytes from, final
Bytes to, final boolean forward) {
- if (transactionBuffer != null) {
- return transactionBuffer.range(from, to, forward, true);
- }
- if (from == null && to == null) {
- return getKeyValueIterator(map.keySet(), forward);
- } else if (from == null) {
- return getKeyValueIterator(map.headMap(to, true).keySet(),
forward);
- } else if (to == null) {
- return getKeyValueIterator(map.tailMap(from, true).keySet(),
forward);
- } else if (from.compareTo(to) > 0) {
+ private KeyValueIterator<Bytes, byte[]> range(final Bytes from, final
Bytes to, final boolean forward,
+ final IsolationLevel
isolationLevel) {
+ if (from != null && to != null && from.compareTo(to) > 0) {
LOG.warn("Returning empty iterator for fetch with invalid key
range: from > to. " +
"This may be due to range arguments set in the wrong
order, " +
"or serdes that don't preserve ordering when
lexicographically comparing the serialized bytes. " +
"Note that the built-in numerical serdes do not follow
this for negative numbers");
return KeyValueIterators.emptyIterator();
- } else {
- return getKeyValueIterator(map.subMap(from, true, to,
true).keySet(), forward);
+ }
+ if (transactionBuffer != null) {
+ return transactionBuffer.range(from, to, forward, true,
isolationLevel);
+ }
+ synchronized (this) {
+ if (from == null && to == null) {
+ return getKeyValueIterator(map.keySet(), forward);
+ } else if (from == null) {
+ return getKeyValueIterator(map.headMap(to, true).keySet(),
forward);
+ } else if (to == null) {
+ return getKeyValueIterator(map.tailMap(from, true).keySet(),
forward);
+ } else {
+ return getKeyValueIterator(map.subMap(from, true, to,
true).keySet(), forward);
+ }
}
}
@@ -253,13 +275,13 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
}
@Override
- public synchronized KeyValueIterator<Bytes, byte[]> all() {
- return range(null, null);
+ public KeyValueIterator<Bytes, byte[]> all() {
+ return range(null, null, true, IsolationLevel.READ_UNCOMMITTED);
}
@Override
- public synchronized KeyValueIterator<Bytes, byte[]> reverseAll() {
- return new InMemoryKeyValueIterator(map.keySet(), false);
+ public KeyValueIterator<Bytes, byte[]> reverseAll() {
+ return range(null, null, false, IsolationLevel.READ_UNCOMMITTED);
}
@Override
@@ -267,6 +289,67 @@ public class InMemoryKeyValueStore implements
KeyValueStore<Bytes, byte[]> {
return map.size();
}
+ @Override
+ public ReadOnlyKeyValueStore<Bytes, byte[]> readOnly(final IsolationLevel
isolationLevel) {
+ Objects.requireNonNull(isolationLevel, "isolationLevel cannot be
null");
+ return new ReadOnlyView(isolationLevel);
+ }
+
+ /**
+ * Read-only view of this store bound to an isolation level. Every read
delegates to the store's
+ * shared read helper with this view's {@code isolationLevel}, so the view
and the store's own
+ * reads follow one code path; under READ_COMMITTED that path excludes the
transaction buffer's
+ * staging layer and snapshots the committed base under the buffer's
read-lock, so an interactive
+ * query cannot race a concurrent commit.
+ */
+ private final class ReadOnlyView implements ReadOnlyKeyValueStore<Bytes,
byte[]> {
+
+ private final IsolationLevel isolationLevel;
+
+ ReadOnlyView(final IsolationLevel isolationLevel) {
+ this.isolationLevel = isolationLevel;
+ }
+
+ @Override
+ public byte[] get(final Bytes key) {
+ Objects.requireNonNull(key, "key cannot be null");
+ return InMemoryKeyValueStore.this.get(key, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Bytes, byte[]> range(final Bytes from, final
Bytes to) {
+ return InMemoryKeyValueStore.this.range(from, to, true,
isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Bytes, byte[]> reverseRange(final Bytes from,
final Bytes to) {
+ return InMemoryKeyValueStore.this.range(from, to, false,
isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Bytes, byte[]> all() {
+ return InMemoryKeyValueStore.this.range(null, null, true,
isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Bytes, byte[]> reverseAll() {
+ return InMemoryKeyValueStore.this.range(null, null, false,
isolationLevel);
+ }
+
+ @Override
+ public <PS extends Serializer<P>, P> KeyValueIterator<Bytes, byte[]>
prefixScan(final P prefix,
+
final PS prefixKeySerializer) {
+ Objects.requireNonNull(prefix, "prefix cannot be null");
+ Objects.requireNonNull(prefixKeySerializer, "prefixKeySerializer
cannot be null");
+ return InMemoryKeyValueStore.this.prefixScan(prefix,
prefixKeySerializer, isolationLevel);
+ }
+
+ @Override
+ public long approximateNumEntries() {
+ return InMemoryKeyValueStore.this.approximateNumEntries();
+ }
+ }
+
@Override
public long approximateNumUncommittedBytes() {
if (transactionBuffer != null) {
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
index b44389772bc..0b36f5b11c4 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionStore.java
@@ -17,6 +17,7 @@
package org.apache.kafka.streams.state.internals;
import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.IsolationLevel;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.metrics.Sensor;
import org.apache.kafka.common.utils.Bytes;
@@ -37,11 +38,13 @@ 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.KeyValueIterator;
+import org.apache.kafka.streams.state.ReadOnlySessionStore;
import org.apache.kafka.streams.state.SessionStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.time.Instant;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
@@ -252,6 +255,13 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
public byte[] fetchSession(final Bytes key,
final long sessionStartTime,
final long sessionEndTime) {
+ return fetchSession(key, sessionStartTime, sessionEndTime,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private byte[] fetchSession(final Bytes key,
+ final long sessionStartTime,
+ final long sessionEndTime,
+ final IsolationLevel isolationLevel) {
removeExpiredSegments();
Objects.requireNonNull(key, "key cannot be null");
@@ -259,10 +269,15 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
// Only need to search if the record hasn't expired yet
if (sessionEndTime > observedStreamTime - retentionPeriod) {
if (transactionBuffer != null) {
- final Optional<byte[]> staged = transactionBuffer.get(key,
sessionStartTime, sessionEndTime);
- if (staged != null) {
- return staged.orElse(null);
+ if (isolationLevel == IsolationLevel.READ_UNCOMMITTED) {
+ final Optional<byte[]> staged = transactionBuffer.get(key,
sessionStartTime, sessionEndTime);
+ if (staged != null) {
+ return staged.orElse(null);
+ }
}
+ // Committed read of the base map, taken under the buffer's
snapshot read-lock so it
+ // cannot race a concurrent commit.
+ return transactionBuffer.getCommitted(key, sessionStartTime,
sessionEndTime);
}
final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long,
byte[]>> keyMap = endTimeMap.get(sessionEndTime);
@@ -279,11 +294,17 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> findSessions(final long
earliestSessionEndTime,
final long
latestSessionEndTime) {
+ return findSessions(earliestSessionEndTime, latestSessionEndTime,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]> findSessions(final long
earliestSessionEndTime,
+ final long
latestSessionEndTime,
+ final
IsolationLevel isolationLevel) {
removeExpiredSegments();
if (transactionBuffer != null) {
return newTransactionalSessionIterator(
- null, null, Long.MAX_VALUE, earliestSessionEndTime,
latestSessionEndTime, true
+ null, null, Long.MAX_VALUE, earliestSessionEndTime,
latestSessionEndTime, true, isolationLevel
);
}
@@ -297,44 +318,38 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
public KeyValueIterator<Windowed<Bytes>, byte[]> findSessions(final Bytes
key,
final long
earliestSessionEndTime,
final long
latestSessionStartTime) {
- Objects.requireNonNull(key, "key cannot be null");
-
- removeExpiredSegments();
-
- if (transactionBuffer != null) {
- return newTransactionalSessionIterator(
- key, key, latestSessionStartTime, earliestSessionEndTime,
Long.MAX_VALUE, true
- );
- }
-
- return registerNewIterator(key,
- key,
- latestSessionStartTime,
- endTimeMap.tailMap(earliestSessionEndTime,
true).entrySet().iterator(),
- true);
+ return findSessionsForKey(key, earliestSessionEndTime,
latestSessionStartTime, true, IsolationLevel.READ_UNCOMMITTED);
}
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]>
backwardFindSessions(final Bytes key,
final long earliestSessionEndTime,
final long latestSessionStartTime) {
+ return findSessionsForKey(key, earliestSessionEndTime,
latestSessionStartTime, false, IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]> findSessionsForKey(final
Bytes key,
+ final
long earliestSessionEndTime,
+ final
long latestSessionStartTime,
+ final
boolean forward,
+ final
IsolationLevel isolationLevel) {
Objects.requireNonNull(key, "key cannot be null");
removeExpiredSegments();
if (transactionBuffer != null) {
return newTransactionalSessionIterator(
- key, key, latestSessionStartTime, earliestSessionEndTime,
Long.MAX_VALUE, false
+ key, key, latestSessionStartTime, earliestSessionEndTime,
Long.MAX_VALUE, forward, isolationLevel
);
}
- return registerNewIterator(
- key,
- key,
- latestSessionStartTime,
- endTimeMap.tailMap(earliestSessionEndTime,
true).descendingMap().entrySet().iterator(),
- false
- );
+ final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> endTimeSubMap =
+ endTimeMap.tailMap(earliestSessionEndTime, true);
+ final Iterator<Entry<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>>> endTimeIter =
+ forward
+ ? endTimeSubMap.entrySet().iterator()
+ : endTimeSubMap.descendingMap().entrySet().iterator();
+ return registerNewIterator(key, key, latestSessionStartTime,
endTimeIter, forward);
}
@Override
@@ -342,24 +357,7 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
final Bytes
keyTo,
final long
earliestSessionEndTime,
final long
latestSessionStartTime) {
- removeExpiredSegments();
-
- if (keyFrom != null && keyTo != null && keyFrom.compareTo(keyTo) > 0) {
- LOG.warn(INVALID_RANGE_WARN_MSG);
- return KeyValueIterators.emptyIterator();
- }
-
- if (transactionBuffer != null) {
- return newTransactionalSessionIterator(
- keyFrom, keyTo, latestSessionStartTime,
earliestSessionEndTime, Long.MAX_VALUE, true
- );
- }
-
- return registerNewIterator(keyFrom,
- keyTo,
- latestSessionStartTime,
- endTimeMap.tailMap(earliestSessionEndTime,
true).entrySet().iterator(),
- true);
+ return findSessionsForKeyRange(keyFrom, keyTo, earliestSessionEndTime,
latestSessionStartTime, true, IsolationLevel.READ_UNCOMMITTED);
}
@Override
@@ -367,6 +365,15 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
final Bytes keyTo,
final long earliestSessionEndTime,
final long latestSessionStartTime) {
+ return findSessionsForKeyRange(keyFrom, keyTo, earliestSessionEndTime,
latestSessionStartTime, false, IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]>
findSessionsForKeyRange(final Bytes keyFrom,
+
final Bytes keyTo,
+
final long earliestSessionEndTime,
+
final long latestSessionStartTime,
+
final boolean forward,
+
final IsolationLevel isolationLevel) {
removeExpiredSegments();
if (keyFrom != null && keyTo != null && keyFrom.compareTo(keyTo) > 0) {
@@ -376,68 +383,68 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
if (transactionBuffer != null) {
return newTransactionalSessionIterator(
- keyFrom, keyTo, latestSessionStartTime,
earliestSessionEndTime, Long.MAX_VALUE, false
+ keyFrom, keyTo, latestSessionStartTime,
earliestSessionEndTime, Long.MAX_VALUE, forward, isolationLevel
);
}
- return registerNewIterator(
- keyFrom,
- keyTo,
- latestSessionStartTime,
- endTimeMap.tailMap(earliestSessionEndTime,
true).descendingMap().entrySet().iterator(),
- false
- );
+ final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> endTimeSubMap =
+ endTimeMap.tailMap(earliestSessionEndTime, true);
+ final Iterator<Entry<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>>> endTimeIter =
+ forward
+ ? endTimeSubMap.entrySet().iterator()
+ : endTimeSubMap.descendingMap().entrySet().iterator();
+ return registerNewIterator(keyFrom, keyTo, latestSessionStartTime,
endTimeIter, forward);
}
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes key) {
-
- Objects.requireNonNull(key, "key cannot be null");
-
- removeExpiredSegments();
-
- if (transactionBuffer != null) {
- return newTransactionalSessionIterator(key, key, Long.MAX_VALUE,
0, Long.MAX_VALUE, true);
- }
-
- return registerNewIterator(key, key, Long.MAX_VALUE,
endTimeMap.entrySet().iterator(), true);
+ return fetchForKey(key, true, IsolationLevel.READ_UNCOMMITTED);
}
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> backwardFetch(final Bytes
key) {
+ return fetchForKey(key, false, IsolationLevel.READ_UNCOMMITTED);
+ }
+ private KeyValueIterator<Windowed<Bytes>, byte[]> fetchForKey(final Bytes
key,
+ final
boolean forward,
+ final
IsolationLevel isolationLevel) {
Objects.requireNonNull(key, "key cannot be null");
removeExpiredSegments();
if (transactionBuffer != null) {
- return newTransactionalSessionIterator(key, key, Long.MAX_VALUE,
0, Long.MAX_VALUE, false);
+ return newTransactionalSessionIterator(key, key, Long.MAX_VALUE,
0, Long.MAX_VALUE, forward, isolationLevel);
}
- return registerNewIterator(key, key, Long.MAX_VALUE,
endTimeMap.descendingMap().entrySet().iterator(), false);
+ final Iterator<Entry<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>>> endTimeIter =
+ forward ? endTimeMap.entrySet().iterator() :
endTimeMap.descendingMap().entrySet().iterator();
+ return registerNewIterator(key, key, Long.MAX_VALUE, endTimeIter,
forward);
}
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes
keyFrom, final Bytes keyTo) {
- removeExpiredSegments();
-
- if (transactionBuffer != null) {
- return newTransactionalSessionIterator(keyFrom, keyTo,
Long.MAX_VALUE, 0, Long.MAX_VALUE, true);
- }
-
- return registerNewIterator(keyFrom, keyTo, Long.MAX_VALUE,
endTimeMap.entrySet().iterator(), true);
+ return fetchForKeyRange(keyFrom, keyTo, true,
IsolationLevel.READ_UNCOMMITTED);
}
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> backwardFetch(final Bytes
keyFrom, final Bytes keyTo) {
+ return fetchForKeyRange(keyFrom, keyTo, false,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]> fetchForKeyRange(final
Bytes keyFrom,
+ final
Bytes keyTo,
+ final
boolean forward,
+ final
IsolationLevel isolationLevel) {
removeExpiredSegments();
if (transactionBuffer != null) {
- return newTransactionalSessionIterator(keyFrom, keyTo,
Long.MAX_VALUE, 0, Long.MAX_VALUE, false);
+ return newTransactionalSessionIterator(keyFrom, keyTo,
Long.MAX_VALUE, 0, Long.MAX_VALUE, forward, isolationLevel);
}
- return registerNewIterator(
- keyFrom, keyTo, Long.MAX_VALUE,
endTimeMap.descendingMap().entrySet().iterator(), false);
+ final Iterator<Entry<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>>> endTimeIter =
+ forward ? endTimeMap.entrySet().iterator() :
endTimeMap.descendingMap().entrySet().iterator();
+ return registerNewIterator(keyFrom, keyTo, Long.MAX_VALUE,
endTimeIter, forward);
}
@Override
@@ -465,6 +472,86 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
);
}
+ @Override
+ public ReadOnlySessionStore<Bytes, byte[]> readOnly(final IsolationLevel
isolationLevel) {
+ Objects.requireNonNull(isolationLevel, "isolationLevel cannot be
null");
+ return new ReadOnlyView(isolationLevel);
+ }
+
+ /**
+ * Read-only view of this store. For a transactional store the {@code
isolationLevel} is passed
+ * down to the transactional read path (which excludes the staging layer
under READ_COMMITTED), so
+ * both isolation levels share the one path and the read is snapshotted
under the buffer's
+ * read-lock — an interactive query cannot race a concurrent commit. For a
non-transactional store
+ * reads hit {@code endTimeMap} directly.
+ */
+ private final class ReadOnlyView implements ReadOnlySessionStore<Bytes,
byte[]> {
+
+ private final IsolationLevel isolationLevel;
+
+ ReadOnlyView(final IsolationLevel isolationLevel) {
+ this.isolationLevel = isolationLevel;
+ }
+
+ @Override
+ public byte[] fetchSession(final Bytes key, final long startTime,
final long endTime) {
+ return InMemorySessionStore.this.fetchSession(key, startTime,
endTime, isolationLevel);
+ }
+
+ @Override
+ public byte[] fetchSession(final Bytes key, final Instant startTime,
final Instant endTime) {
+ return fetchSession(key, startTime.toEpochMilli(),
endTime.toEpochMilli());
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> findSessions(final
Bytes key,
+ final
long earliestSessionEndTime,
+ final
long latestSessionStartTime) {
+ return InMemorySessionStore.this.findSessionsForKey(key,
earliestSessionEndTime, latestSessionStartTime, true, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]>
backwardFindSessions(final Bytes key,
+
final long earliestSessionEndTime,
+
final long latestSessionStartTime) {
+ return InMemorySessionStore.this.findSessionsForKey(key,
earliestSessionEndTime, latestSessionStartTime, false, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> findSessions(final
Bytes keyFrom, final Bytes keyTo,
+ final
long earliestSessionEndTime,
+ final
long latestSessionStartTime) {
+ return InMemorySessionStore.this.findSessionsForKeyRange(keyFrom,
keyTo, earliestSessionEndTime, latestSessionStartTime, true, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]>
backwardFindSessions(final Bytes keyFrom, final Bytes keyTo,
+
final long earliestSessionEndTime,
+
final long latestSessionStartTime) {
+ return InMemorySessionStore.this.findSessionsForKeyRange(keyFrom,
keyTo, earliestSessionEndTime, latestSessionStartTime, false, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes
key) {
+ return InMemorySessionStore.this.fetchForKey(key, true,
isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> backwardFetch(final
Bytes key) {
+ return InMemorySessionStore.this.fetchForKey(key, false,
isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes
keyFrom, final Bytes keyTo) {
+ return InMemorySessionStore.this.fetchForKeyRange(keyFrom, keyTo,
true, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> backwardFetch(final
Bytes keyFrom, final Bytes keyTo) {
+ return InMemorySessionStore.this.fetchForKeyRange(keyFrom, keyTo,
false, isolationLevel);
+ }
+ }
+
@Override
public long approximateNumUncommittedBytes() {
if (transactionBuffer != null) {
@@ -526,11 +613,12 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
final long latestSessionStartTime,
final long earliestSessionEndTime,
final long latestSessionEndTime,
- final boolean forward) {
+ final boolean forward,
+ final IsolationLevel isolationLevel) {
final TransactionalSessionIterator iterator = new
TransactionalSessionIterator(
transactionBuffer, keyFrom, keyTo, latestSessionStartTime,
earliestSessionEndTime, latestSessionEndTime,
- observedStreamTime - retentionPeriod, forward,
openTransactionalIterators::remove
+ observedStreamTime - retentionPeriod, forward, isolationLevel,
openTransactionalIterators::remove
);
openTransactionalIterators.add(iterator);
return iterator;
@@ -578,6 +666,7 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
final long latestSessionEndTime,
final long oldestRetainedEndTime,
final boolean forward,
+ final IsolationLevel isolationLevel,
final Consumer<TransactionalSessionIterator> deregister) {
this.keyFrom = keyFrom;
this.keyTo = keyTo;
@@ -592,7 +681,7 @@ public class InMemorySessionStore implements
SessionStore<Bytes, byte[]>, WithRe
final InMemorySessionTransactionBuffer.SessionEntryKey to =
new
InMemorySessionTransactionBuffer.SessionEntryKey(latestSessionEndTime, keyTo,
0);
- this.delegate = buffer.range(from, to, forward, true);
+ this.delegate = buffer.range(from, to, forward, true,
isolationLevel);
}
@Override
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
index 00e794a7fcb..00d4e8246fd 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemorySessionTransactionBuffer.java
@@ -160,6 +160,39 @@ class InMemorySessionTransactionBuffer extends
AbstractTransactionBuffer<InMemor
timeRange = endTimeMap;
}
+ final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> copy = deepCopy(timeRange);
+ return baseIterator(forward ? copy : copy.descendingMap(), from, to,
forward);
+ }
+
+ /**
+ * Committed-only point read for a single session, bypassing the staging
layer. Non-owner (IQ)
+ * reads take the snapshot read-lock so the read reflects a single
committed state rather than a
+ * commit in progress.
+ */
+ byte[] getCommitted(final Bytes key, final long startTime, final long
endTime) {
+ if (Thread.currentThread() == ownerThread) {
+ return baseGet(key, startTime, endTime);
+ }
+ snapshotLock.readLock().lock();
+ try {
+ return baseGet(key, startTime, endTime);
+ } finally {
+ snapshotLock.readLock().unlock();
+ }
+ }
+
+ private byte[] baseGet(final Bytes key, final long startTime, final long
endTime) {
+ final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long,
byte[]>> keyMap = endTimeMap.get(endTime);
+ if (keyMap == null) {
+ return null;
+ }
+ final ConcurrentNavigableMap<Long, byte[]> startTimeMap =
keyMap.get(key);
+ return startTimeMap == null ? null : startTimeMap.get(startTime);
+ }
+
+ /** Deep-copies a bounded end-time range into a private map so iterators
are isolated from later mutation. */
+ private static ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> deepCopy(
+ final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> timeRange) {
final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> copy = new ConcurrentSkipListMap<>();
for (final Map.Entry<Long, ConcurrentNavigableMap<Bytes,
ConcurrentNavigableMap<Long, byte[]>>> endTimeEntry : timeRange.entrySet()) {
final ConcurrentNavigableMap<Bytes, ConcurrentNavigableMap<Long,
byte[]>> keyCopy = new ConcurrentSkipListMap<>();
@@ -168,8 +201,7 @@ class InMemorySessionTransactionBuffer extends
AbstractTransactionBuffer<InMemor
}
copy.put(endTimeEntry.getKey(), keyCopy);
}
-
- return baseIterator(forward ? copy : copy.descendingMap(), from, to,
forward);
+ return copy;
}
/**
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 b1b2f319f5c..26a8ef8f61c 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
@@ -81,6 +81,27 @@ class InMemoryTransactionBuffer extends
AbstractTransactionBuffer<Bytes> {
return new BaseMapIterator(forward ? copy : copy.descendingMap());
}
+ /**
+ * Committed-only point read. Bypasses the staging layer and reads the
base map directly.
+ * <p>
+ * The owner reads lock-free (it is the sole mutator and single-threaded).
Non-owner (IQ) reads
+ * take the snapshot read-lock: {@code commit}/{@code rollback} mutate the
non-thread-safe base
+ * {@link java.util.TreeMap} under the write-lock, so an unlocked read
could traverse a
+ * mid-rebalance tree (wrong value/NPE) or observe a partially-applied
commit. The read-lock also
+ * supplies the happens-before edge that makes committed writes visible.
+ */
+ byte[] getCommitted(final Bytes key) {
+ if (Thread.currentThread() == ownerThread) {
+ return baseMap.get(key);
+ }
+ snapshotLock.readLock().lock();
+ try {
+ return baseMap.get(key);
+ } finally {
+ snapshotLock.readLock().unlock();
+ }
+ }
+
private NavigableMap<Bytes, byte[]> boundView(final Bytes from, final
Bytes to, final boolean toInclusive) {
if (from != null && to != null) {
return baseMap.subMap(from, true, to, toInclusive);
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
index 26f9634326b..a9bdc43cc50 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowStore.java
@@ -17,6 +17,7 @@
package org.apache.kafka.streams.state.internals;
import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.IsolationLevel;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.metrics.Sensor;
import org.apache.kafka.common.utils.Bytes;
@@ -38,6 +39,7 @@ 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.KeyValueIterator;
+import org.apache.kafka.streams.state.ReadOnlyWindowStore;
import org.apache.kafka.streams.state.WindowStore;
import org.apache.kafka.streams.state.WindowStoreIterator;
@@ -45,6 +47,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.ByteBuffer;
+import java.time.Instant;
import java.util.Iterator;
import java.util.Map;
import java.util.NoSuchElementException;
@@ -221,6 +224,10 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
@Override
public byte[] fetch(final Bytes key, final long windowStartTimestamp) {
+ return fetch(key, windowStartTimestamp,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private byte[] fetch(final Bytes key, final long windowStartTimestamp,
final IsolationLevel isolationLevel) {
Objects.requireNonNull(key, "key cannot be null");
removeExpiredSegments();
@@ -230,10 +237,15 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
}
if (transactionBuffer != null) {
- final Optional<byte[]> staged =
transactionBuffer.get(windowStartTimestamp, key);
- if (staged != null) {
- return staged.orElse(null);
+ if (isolationLevel == IsolationLevel.READ_UNCOMMITTED) {
+ final Optional<byte[]> staged =
transactionBuffer.get(windowStartTimestamp, key);
+ if (staged != null) {
+ return staged.orElse(null);
+ }
}
+ // Committed read of the base map, taken under the buffer's
snapshot read-lock so it
+ // cannot race a concurrent commit.
+ return transactionBuffer.getCommitted(windowStartTimestamp, key);
}
final ConcurrentNavigableMap<Bytes, byte[]> kvMap =
segmentMap.get(windowStartTimestamp);
@@ -255,6 +267,11 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
}
WindowStoreIterator<byte[]> fetch(final Bytes key, final long timeFrom,
final long timeTo, final boolean forward) {
+ return fetch(key, timeFrom, timeTo, forward,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private WindowStoreIterator<byte[]> fetch(final Bytes key, final long
timeFrom, final long timeTo,
+ final boolean forward, final
IsolationLevel isolationLevel) {
Objects.requireNonNull(key, "key cannot be null");
removeExpiredSegments();
@@ -270,7 +287,7 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final Bytes keyFrom = retainDuplicates ? wrapForDups(key, 0) : key;
final Bytes keyTo = retainDuplicates ? wrapForDups(key,
Integer.MAX_VALUE) : key;
return registerTransactional(new TransactionalWindowStoreIterator(
- transactionBuffer, keyFrom, keyTo, minTime, timeTo, forward,
retainDuplicates,
+ transactionBuffer, keyFrom, keyTo, minTime, timeTo, forward,
retainDuplicates, isolationLevel,
openTransactionalIterators::remove
));
}
@@ -278,15 +295,13 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
if (forward) {
return registerNewWindowStoreIterator(
key,
- segmentMap.subMap(minTime, true, timeTo, true)
- .entrySet().iterator(),
+ segmentMap.subMap(minTime, true, timeTo,
true).entrySet().iterator(),
true
);
} else {
return registerNewWindowStoreIterator(
key,
- segmentMap.subMap(minTime, true, timeTo, true)
- .descendingMap().entrySet().iterator(),
+ segmentMap.subMap(minTime, true, timeTo,
true).descendingMap().entrySet().iterator(),
false
);
}
@@ -313,6 +328,15 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final long timeFrom,
final long timeTo,
final boolean forward) {
+ return fetch(from, to, timeFrom, timeTo, forward,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes from,
+ final Bytes to,
+ final long
timeFrom,
+ final long timeTo,
+ final boolean
forward,
+ final
IsolationLevel isolationLevel) {
removeExpiredSegments();
if (from != null && to != null && from.compareTo(to) > 0) {
@@ -334,7 +358,8 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final Bytes keyFrom = (retainDuplicates && from != null) ?
wrapForDups(from, 0) : from;
final Bytes keyTo = (retainDuplicates && to != null) ?
wrapForDups(to, Integer.MAX_VALUE) : to;
return registerTransactional(new
TransactionalWindowedKeyValueIterator(
- transactionBuffer, keyFrom, keyTo, minTime, timeTo, forward,
retainDuplicates, windowSize, openTransactionalIterators::remove
+ transactionBuffer, keyFrom, keyTo, minTime, timeTo, forward,
retainDuplicates, windowSize, isolationLevel,
+ openTransactionalIterators::remove
));
}
@@ -342,16 +367,14 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
return registerNewWindowedKeyValueIterator(
from,
to,
- segmentMap.subMap(minTime, true, timeTo, true)
- .entrySet().iterator(),
+ segmentMap.subMap(minTime, true, timeTo,
true).entrySet().iterator(),
true
);
} else {
return registerNewWindowedKeyValueIterator(
from,
to,
- segmentMap.subMap(minTime, true, timeTo, true)
- .descendingMap().entrySet().iterator(),
+ segmentMap.subMap(minTime, true, timeTo,
true).descendingMap().entrySet().iterator(),
false
);
}
@@ -368,6 +391,12 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
}
KeyValueIterator<Windowed<Bytes>, byte[]> fetchAll(final long timeFrom,
final long timeTo, final boolean forward) {
+ return fetchAll(timeFrom, timeTo, forward,
IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]> fetchAll(final long
timeFrom, final long timeTo,
+ final boolean
forward,
+ final
IsolationLevel isolationLevel) {
removeExpiredSegments();
// add one b/c records expire exactly retentionPeriod ms after created
@@ -379,7 +408,8 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
if (transactionBuffer != null) {
return registerTransactional(new
TransactionalWindowedKeyValueIterator(
- transactionBuffer, null, null, minTime, timeTo, forward,
retainDuplicates, windowSize, openTransactionalIterators::remove
+ transactionBuffer, null, null, minTime, timeTo, forward,
retainDuplicates, windowSize, isolationLevel,
+ openTransactionalIterators::remove
));
}
@@ -387,16 +417,14 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
return registerNewWindowedKeyValueIterator(
null,
null,
- segmentMap.subMap(minTime, true, timeTo, true)
- .entrySet().iterator(),
+ segmentMap.subMap(minTime, true, timeTo,
true).entrySet().iterator(),
true
);
} else {
return registerNewWindowedKeyValueIterator(
null,
null,
- segmentMap.subMap(minTime, true, timeTo, true)
- .descendingMap().entrySet().iterator(),
+ segmentMap.subMap(minTime, true, timeTo,
true).descendingMap().entrySet().iterator(),
false
);
}
@@ -404,42 +432,31 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> all() {
- removeExpiredSegments();
-
- final long minTime = observedStreamTime - retentionPeriod;
-
- if (transactionBuffer != null) {
- return registerTransactional(new
TransactionalWindowedKeyValueIterator(
- transactionBuffer, null, null, minTime + 1, Long.MAX_VALUE,
true, retainDuplicates, windowSize, openTransactionalIterators::remove
- ));
- }
-
- return registerNewWindowedKeyValueIterator(
- null,
- null,
- segmentMap.tailMap(minTime, false).entrySet().iterator(),
- true
- );
+ return all(true, IsolationLevel.READ_UNCOMMITTED);
}
@Override
public KeyValueIterator<Windowed<Bytes>, byte[]> backwardAll() {
+ return all(false, IsolationLevel.READ_UNCOMMITTED);
+ }
+
+ private KeyValueIterator<Windowed<Bytes>, byte[]> all(final boolean
forward,
+ final IsolationLevel
isolationLevel) {
removeExpiredSegments();
final long minTime = observedStreamTime - retentionPeriod;
if (transactionBuffer != null) {
return registerTransactional(new
TransactionalWindowedKeyValueIterator(
- transactionBuffer, null, null, minTime + 1, Long.MAX_VALUE,
false, retainDuplicates, windowSize, openTransactionalIterators::remove
+ transactionBuffer, null, null, minTime + 1, Long.MAX_VALUE,
forward, retainDuplicates, windowSize, isolationLevel,
+ openTransactionalIterators::remove
));
}
- return registerNewWindowedKeyValueIterator(
- null,
- null,
- segmentMap.tailMap(minTime,
false).descendingMap().entrySet().iterator(),
- false
- );
+ final Iterator<Map.Entry<Long, ConcurrentNavigableMap<Bytes, byte[]>>>
segIter = forward
+ ? segmentMap.tailMap(minTime, false).entrySet().iterator()
+ : segmentMap.tailMap(minTime,
false).descendingMap().entrySet().iterator();
+ return registerNewWindowedKeyValueIterator(null, null, segIter,
forward);
}
@Override
@@ -467,6 +484,75 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
);
}
+ @Override
+ public ReadOnlyWindowStore<Bytes, byte[]> readOnly(final IsolationLevel
isolationLevel) {
+ Objects.requireNonNull(isolationLevel, "isolationLevel cannot be
null");
+ return new ReadOnlyView(isolationLevel);
+ }
+
+ /**
+ * Read-only view of this store. For a transactional store the {@code
isolationLevel} is passed
+ * down to the transactional read path (which excludes the staging layer
under READ_COMMITTED), so
+ * both isolation levels share the one path and the read is snapshotted
under the buffer's
+ * read-lock — an interactive query cannot race a concurrent commit. For a
non-transactional store
+ * reads hit {@code segmentMap} directly.
+ */
+ private final class ReadOnlyView implements ReadOnlyWindowStore<Bytes,
byte[]> {
+
+ private final IsolationLevel isolationLevel;
+
+ ReadOnlyView(final IsolationLevel isolationLevel) {
+ this.isolationLevel = isolationLevel;
+ }
+
+ @Override
+ public byte[] fetch(final Bytes key, final long time) {
+ return InMemoryWindowStore.this.fetch(key, time, isolationLevel);
+ }
+
+ @Override
+ public WindowStoreIterator<byte[]> fetch(final Bytes key, final
Instant timeFrom, final Instant timeTo) {
+ return InMemoryWindowStore.this.fetch(key,
timeFrom.toEpochMilli(), timeTo.toEpochMilli(), true, isolationLevel);
+ }
+
+ @Override
+ public WindowStoreIterator<byte[]> backwardFetch(final Bytes key,
final Instant timeFrom, final Instant timeTo) {
+ return InMemoryWindowStore.this.fetch(key,
timeFrom.toEpochMilli(), timeTo.toEpochMilli(), false, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> fetch(final Bytes
keyFrom, final Bytes keyTo,
+ final Instant
timeFrom, final Instant timeTo) {
+ return InMemoryWindowStore.this.fetch(keyFrom, keyTo,
timeFrom.toEpochMilli(), timeTo.toEpochMilli(), true, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> backwardFetch(final
Bytes keyFrom, final Bytes keyTo,
+ final
Instant timeFrom, final Instant timeTo) {
+ return InMemoryWindowStore.this.fetch(keyFrom, keyTo,
timeFrom.toEpochMilli(), timeTo.toEpochMilli(), false, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> fetchAll(final
Instant timeFrom, final Instant timeTo) {
+ return InMemoryWindowStore.this.fetchAll(timeFrom.toEpochMilli(),
timeTo.toEpochMilli(), true, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]>
backwardFetchAll(final Instant timeFrom, final Instant timeTo) {
+ return InMemoryWindowStore.this.fetchAll(timeFrom.toEpochMilli(),
timeTo.toEpochMilli(), false, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> all() {
+ return InMemoryWindowStore.this.all(true, isolationLevel);
+ }
+
+ @Override
+ public KeyValueIterator<Windowed<Bytes>, byte[]> backwardAll() {
+ return InMemoryWindowStore.this.all(false, isolationLevel);
+ }
+ }
+
@Override
public long approximateNumUncommittedBytes() {
if (transactionBuffer != null) {
@@ -624,6 +710,7 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final long timeTo,
final boolean forward,
final boolean retainDuplicates,
+ final IsolationLevel isolationLevel,
final Consumer<KeyValueIterator<?, ?>> deregister) {
this.retainDuplicates = retainDuplicates;
this.deregister = deregister;
@@ -634,7 +721,7 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final InMemoryWindowTransactionBuffer.WindowEntryKey to =
new InMemoryWindowTransactionBuffer.WindowEntryKey(timeTo,
keyTo);
- this.delegate = buffer.range(from, to, forward, true);
+ this.delegate = buffer.range(from, to, forward, true,
isolationLevel);
}
@Override
@@ -712,6 +799,7 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final boolean forward,
final boolean retainDuplicates,
final long windowSize,
+ final IsolationLevel isolationLevel,
final Consumer<KeyValueIterator<?, ?>> deregister) {
this.retainDuplicates = retainDuplicates;
this.windowSize = windowSize;
@@ -723,7 +811,7 @@ public class InMemoryWindowStore implements
WindowStore<Bytes, byte[]>, WithRete
final InMemoryWindowTransactionBuffer.WindowEntryKey to =
new InMemoryWindowTransactionBuffer.WindowEntryKey(timeTo,
keyTo);
- this.delegate = buffer.range(from, to, forward, true);
+ this.delegate = buffer.range(from, to, forward, true,
isolationLevel);
}
@Override
diff --git
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
index 1fd2ac9f753..03fe8ca35b7 100644
---
a/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
+++
b/streams/src/main/java/org/apache/kafka/streams/state/internals/InMemoryWindowTransactionBuffer.java
@@ -151,12 +151,40 @@ class InMemoryWindowTransactionBuffer extends
AbstractTransactionBuffer<InMemory
timeRange = segmentMap;
}
+ final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
byte[]>> copy = deepCopy(timeRange);
+ return baseIterator(forward ? copy : copy.descendingMap(), from, to,
forward);
+ }
+
+ /**
+ * Committed-only point read for a single window, bypassing the staging
layer. Non-owner (IQ)
+ * reads take the snapshot read-lock so the read reflects a single
committed state rather than a
+ * commit in progress.
+ */
+ byte[] getCommitted(final long timestamp, final Bytes key) {
+ if (Thread.currentThread() == ownerThread) {
+ return baseGet(timestamp, key);
+ }
+ snapshotLock.readLock().lock();
+ try {
+ return baseGet(timestamp, key);
+ } finally {
+ snapshotLock.readLock().unlock();
+ }
+ }
+
+ private byte[] baseGet(final long timestamp, final Bytes key) {
+ final ConcurrentNavigableMap<Bytes, byte[]> kvMap =
segmentMap.get(timestamp);
+ return kvMap == null ? null : kvMap.get(key);
+ }
+
+ /** Deep-copies a bounded segment range into a private map so iterators
are isolated from later mutation. */
+ private static ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
byte[]>> deepCopy(
+ final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
byte[]>> timeRange) {
final ConcurrentNavigableMap<Long, ConcurrentNavigableMap<Bytes,
byte[]>> copy = new ConcurrentSkipListMap<>();
for (final Map.Entry<Long, ConcurrentNavigableMap<Bytes, byte[]>>
segment : timeRange.entrySet()) {
copy.put(segment.getKey(), new
ConcurrentSkipListMap<>(segment.getValue()));
}
-
- return baseIterator(forward ? copy : copy.descendingMap(), from, to,
forward);
+ return copy;
}
/**
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/ChangeLoggingKeyValueBytesStoreTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/ChangeLoggingKeyValueBytesStoreTest.java
index 65ffd0cffba..76bd6579297 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/ChangeLoggingKeyValueBytesStoreTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/ChangeLoggingKeyValueBytesStoreTest.java
@@ -38,6 +38,7 @@ import
org.apache.kafka.streams.processor.internals.metrics.StreamsMetricsImpl;
import org.apache.kafka.streams.query.Position;
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.test.InternalMockProcessorContext;
import org.apache.kafka.test.MockRecordCollector;
import org.apache.kafka.test.TestUtils;
@@ -267,15 +268,27 @@ public class ChangeLoggingKeyValueBytesStoreTest {
}
@Test
+ @SuppressWarnings("unchecked")
public void shouldDelegateReadOnlyUncommittedToInner() {
- assertThat(store.readOnly(IsolationLevel.READ_UNCOMMITTED),
- sameInstance(inner.readOnly(IsolationLevel.READ_UNCOMMITTED)));
+ final KeyValueStore<Bytes, byte[]> innerMock =
mock(KeyValueStore.class);
+ final ChangeLoggingKeyValueBytesStore outer = new
ChangeLoggingKeyValueBytesStore(innerMock);
+ final ReadOnlyKeyValueStore<Bytes, byte[]> view =
mock(ReadOnlyKeyValueStore.class);
+
when(innerMock.readOnly(IsolationLevel.READ_UNCOMMITTED)).thenReturn(view);
+
+ assertThat(outer.readOnly(IsolationLevel.READ_UNCOMMITTED),
sameInstance(view));
+ verify(innerMock).readOnly(IsolationLevel.READ_UNCOMMITTED);
}
@Test
+ @SuppressWarnings("unchecked")
public void shouldDelegateReadOnlyCommittedToInner() {
- assertThat(store.readOnly(IsolationLevel.READ_COMMITTED),
- sameInstance(inner.readOnly(IsolationLevel.READ_COMMITTED)));
+ final KeyValueStore<Bytes, byte[]> innerMock =
mock(KeyValueStore.class);
+ final ChangeLoggingKeyValueBytesStore outer = new
ChangeLoggingKeyValueBytesStore(innerMock);
+ final ReadOnlyKeyValueStore<Bytes, byte[]> view =
mock(ReadOnlyKeyValueStore.class);
+
when(innerMock.readOnly(IsolationLevel.READ_COMMITTED)).thenReturn(view);
+
+ assertThat(outer.readOnly(IsolationLevel.READ_COMMITTED),
sameInstance(view));
+ verify(innerMock).readOnly(IsolationLevel.READ_COMMITTED);
}
private StreamsConfig streamsConfigMock() {
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 e8b0bb31b38..894d88674fc 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
@@ -16,6 +16,7 @@
*/
package org.apache.kafka.streams.state.internals;
+import org.apache.kafka.common.IsolationLevel;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.apache.kafka.common.serialization.Serde;
import org.apache.kafka.common.serialization.Serdes;
@@ -24,14 +25,19 @@ import
org.apache.kafka.common.serialization.StringSerializer;
import org.apache.kafka.common.serialization.UUIDSerializer;
import org.apache.kafka.common.utils.Bytes;
import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.processor.StateStoreContext;
import org.apache.kafka.streams.processor.internals.ProcessorRecordContext;
import org.apache.kafka.streams.query.Position;
import org.apache.kafka.streams.state.KeyValueIterator;
import org.apache.kafka.streams.state.KeyValueStore;
import org.apache.kafka.streams.state.KeyValueStoreTestDriver;
+import org.apache.kafka.streams.state.ReadOnlyKeyValueStore;
import org.apache.kafka.streams.state.StoreBuilder;
import org.apache.kafka.streams.state.Stores;
+import org.apache.kafka.test.InternalMockProcessorContext;
+import org.apache.kafka.test.StreamsTestUtils;
+import org.apache.kafka.test.TestUtils;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@@ -41,7 +47,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
+import java.util.Properties;
import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import static org.apache.kafka.common.utils.Utils.mkEntry;
import static org.apache.kafka.common.utils.Utils.mkMap;
@@ -439,6 +449,172 @@ public class InMemoryKeyValueStoreTest extends
AbstractKeyValueStoreTest {
assertThrows(IllegalStateException.class, iter::peekNextKey);
}
+ @Test
+ public void readOnlyCommittedShouldHideStagedWritesFromTransactionBuffer()
{
+ final InMemoryKeyValueStore txnStore = openTransactionalStore();
+ try {
+ final Bytes k1 = bytesKey("k1");
+ final Bytes k2 = bytesKey("k2");
+ txnStore.put(k1, bytesValue("v1"));
+ txnStore.commit(Map.of());
+
+ txnStore.put(k1, bytesValue("v1-staged"));
+ txnStore.put(k2, bytesValue("v2-staged"));
+
+ final ReadOnlyKeyValueStore<Bytes, byte[]> uncommitted =
txnStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+ final ReadOnlyKeyValueStore<Bytes, byte[]> committed =
txnStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+ assertArrayEquals(bytesValue("v1-staged"), uncommitted.get(k1));
+ assertArrayEquals(bytesValue("v2-staged"), uncommitted.get(k2));
+ assertArrayEquals(bytesValue("v1"), committed.get(k1));
+ assertThat(committed.get(k2), nullValue());
+
+ try (KeyValueIterator<Bytes, byte[]> it = committed.all()) {
+ final List<String> keys = new ArrayList<>();
+ while (it.hasNext()) {
+ keys.add(new String(it.next().key.get()));
+ }
+ assertEquals(List.of("k1"), keys);
+ }
+ try (KeyValueIterator<Bytes, byte[]> it = uncommitted.all()) {
+ final List<String> keys = new ArrayList<>();
+ while (it.hasNext()) {
+ keys.add(new String(it.next().key.get()));
+ }
+ assertEquals(List.of("k1", "k2"), keys);
+ }
+ } finally {
+ txnStore.close();
+ }
+ }
+
+ @Test
+ public void readOnlyCommittedShouldNotSeeStagedDelete() {
+ final InMemoryKeyValueStore txnStore = openTransactionalStore();
+ try {
+ final Bytes k = bytesKey("k");
+ txnStore.put(k, bytesValue("v"));
+ txnStore.commit(Map.of());
+
+ txnStore.delete(k);
+
+
assertThat(txnStore.readOnly(IsolationLevel.READ_UNCOMMITTED).get(k),
nullValue());
+ assertArrayEquals(bytesValue("v"),
txnStore.readOnly(IsolationLevel.READ_COMMITTED).get(k));
+ } finally {
+ txnStore.close();
+ }
+ }
+
+ @Test
+ public void readOnlyPrefixScanShouldRespectIsolationLevel() {
+ final InMemoryKeyValueStore txnStore = openTransactionalStore();
+ try {
+ txnStore.put(bytesKey("p-1"), bytesValue("a"));
+ txnStore.commit(Map.of());
+ txnStore.put(bytesKey("p-2"), bytesValue("b"));
+ txnStore.put(bytesKey("q-1"), bytesValue("z"));
+
+ final List<String> uncommittedKeys = new ArrayList<>();
+ try (KeyValueIterator<Bytes, byte[]> it =
txnStore.readOnly(IsolationLevel.READ_UNCOMMITTED)
+ .prefixScan("p-", stringSerializer)) {
+ while (it.hasNext()) {
+ uncommittedKeys.add(new String(it.next().key.get()));
+ }
+ }
+ final List<String> committedKeys = new ArrayList<>();
+ try (KeyValueIterator<Bytes, byte[]> it =
txnStore.readOnly(IsolationLevel.READ_COMMITTED)
+ .prefixScan("p-", stringSerializer)) {
+ while (it.hasNext()) {
+ committedKeys.add(new String(it.next().key.get()));
+ }
+ }
+ assertEquals(List.of("p-1", "p-2"), uncommittedKeys);
+ assertEquals(List.of("p-1"), committedKeys);
+ } finally {
+ txnStore.close();
+ }
+ }
+
+ @Test
+ public void readOnlyCommittedScanShouldNotRaceConcurrentCommit() throws
InterruptedException {
+ // A READ_COMMITTED scan from a non-owner (IQ) thread must observe a
point-in-time snapshot
+ // of the committed base map, taken under the buffer's snapshot
read-lock. Before the fix the
+ // view copied the live TreeMap under the store monitor, which does
not exclude commit()'s
+ // write-lock-guarded flushToBase(), so a concurrent commit threw
ConcurrentModificationException
+ // and/or exposed a half-applied commit. Each commit flips every key
to a single generation, so
+ // a correct snapshot scan must see one uniform generation.
+ final InMemoryKeyValueStore txnStore = openTransactionalStore();
+ final int numKeys = 500;
+ final int rounds = 300;
+ final AtomicReference<Throwable> readerFailure = new
AtomicReference<>();
+ final AtomicBoolean stop = new AtomicBoolean(false);
+
+ try {
+ for (int i = 0; i < numKeys; i++) {
+ txnStore.put(bytesKey("k" + i), bytesValue("gen0"));
+ }
+ txnStore.commit(Map.of()); // owner thread owns the buffer
+
+ final Thread reader = new Thread(() -> {
+ final ReadOnlyKeyValueStore<Bytes, byte[]> committed =
txnStore.readOnly(IsolationLevel.READ_COMMITTED);
+ try {
+ while (!stop.get()) {
+ try (KeyValueIterator<Bytes, byte[]> it =
committed.all()) {
+ String generation = null;
+ int count = 0;
+ while (it.hasNext()) {
+ final String value = new
String(it.next().value);
+ if (generation == null) {
+ generation = value;
+ } else {
+ assertEquals(generation, value, "scan
observed a partial commit");
+ }
+ count++;
+ }
+ // a committed snapshot is always the full,
uniform key set
+ assertEquals(numKeys, count, "scan observed a
partial commit");
+ }
+ }
+ } catch (final Throwable t) {
+ readerFailure.set(t);
+ }
+ }, "iq-reader");
+ reader.start();
+
+ for (int round = 1; round <= rounds && readerFailure.get() ==
null; round++) {
+ final String generation = "gen" + round;
+ for (int i = 0; i < numKeys; i++) {
+ txnStore.put(bytesKey("k" + i), bytesValue(generation));
+ }
+ txnStore.commit(Map.of());
+ }
+ stop.set(true);
+ reader.join(TimeUnit.SECONDS.toMillis(30));
+
+ if (readerFailure.get() != null) {
+ throw new AssertionError("READ_COMMITTED scan raced a
concurrent commit", readerFailure.get());
+ }
+ } finally {
+ stop.set(true);
+ txnStore.close();
+ }
+ }
+
+ private InMemoryKeyValueStore openTransactionalStore() {
+ 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-in-memory-store");
+ store.init(ctx, store);
+ return store;
+ }
+
private byte[] bytesValue(final String value) {
return value.getBytes();
}
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemorySessionStoreTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemorySessionStoreTest.java
index eca2fa4502f..cfcf156253c 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemorySessionStoreTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemorySessionStoreTest.java
@@ -16,18 +16,32 @@
*/
package org.apache.kafka.streams.state.internals;
+import org.apache.kafka.common.IsolationLevel;
+import org.apache.kafka.common.serialization.Serdes;
import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.StreamsConfig;
import org.apache.kafka.streams.kstream.Windowed;
import org.apache.kafka.streams.kstream.internals.SessionWindow;
import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.ReadOnlySessionStore;
+import org.apache.kafka.test.InternalMockProcessorContext;
+import org.apache.kafka.test.StreamsTestUtils;
+import org.apache.kafka.test.TestUtils;
import org.junit.jupiter.api.Test;
+import java.util.Map;
+import java.util.Properties;
import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import static org.apache.kafka.test.StreamsTestUtils.valuesToSet;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
public class InMemorySessionStoreTest extends AbstractSessionBytesStoreTest {
@@ -120,4 +134,140 @@ public class InMemorySessionStoreTest extends
AbstractSessionBytesStoreTest {
}
}
+ @Test
+ public void readOnlyCommittedShouldHideStagedSessions() {
+ final InMemorySessionStore store = openTransactionalSessionStore();
+ try {
+ final Bytes k = Bytes.wrap("k".getBytes());
+ store.put(new Windowed<>(k, new SessionWindow(0, 10)),
"v1".getBytes());
+ store.commit(Map.of());
+
+ store.put(new Windowed<>(k, new SessionWindow(20, 30)),
"v2".getBytes());
+ store.remove(new Windowed<>(k, new SessionWindow(0, 10)));
+
+ final ReadOnlySessionStore<Bytes, byte[]> uncommitted =
store.readOnly(IsolationLevel.READ_UNCOMMITTED);
+ final ReadOnlySessionStore<Bytes, byte[]> committed =
store.readOnly(IsolationLevel.READ_COMMITTED);
+
+ assertNull(uncommitted.fetchSession(k, 0, 10));
+ assertArrayEquals("v2".getBytes(), uncommitted.fetchSession(k, 20,
30));
+ assertArrayEquals("v1".getBytes(), committed.fetchSession(k, 0,
10));
+ assertNull(committed.fetchSession(k, 20, 30));
+ } finally {
+ store.close();
+ }
+ }
+
+ @Test
+ public void readOnlyFindSessionsShouldRespectIsolationLevel() {
+ final InMemorySessionStore store = openTransactionalSessionStore();
+ try {
+ final Bytes k = Bytes.wrap("k".getBytes());
+ store.put(new Windowed<>(k, new SessionWindow(0, 10)),
"v1".getBytes());
+ store.commit(Map.of());
+ store.put(new Windowed<>(k, new SessionWindow(20, 30)),
"v2".getBytes());
+
+ try (KeyValueIterator<Windowed<Bytes>, byte[]> it =
+
store.readOnly(IsolationLevel.READ_UNCOMMITTED).findSessions(k, 0L, 100L)) {
+ int count = 0;
+ while (it.hasNext()) {
+ it.next();
+ count++;
+ }
+ assertEquals(2, count);
+ }
+ try (KeyValueIterator<Windowed<Bytes>, byte[]> it =
+
store.readOnly(IsolationLevel.READ_COMMITTED).findSessions(k, 0L, 100L)) {
+ int count = 0;
+ while (it.hasNext()) {
+ it.next();
+ count++;
+ }
+ assertEquals(1, count);
+ }
+ } finally {
+ store.close();
+ }
+ }
+
+ @Test
+ public void readOnlyCommittedFindSessionsShouldNotRaceConcurrentCommit()
throws InterruptedException {
+ // A READ_COMMITTED scan from a non-owner (IQ) thread must observe a
point-in-time snapshot of
+ // the committed sessions, taken under the buffer's snapshot
read-lock. Without it the view
+ // iterated the live end-time map while commit()'s write-lock-guarded
flushToBase() mutated it,
+ // exposing a half-applied commit. Each commit flips every session to
a single generation, so a
+ // correct snapshot scan must see one uniform generation across the
full session set.
+ final InMemorySessionStore store = openTransactionalSessionStore();
+ final Bytes k = Bytes.wrap("k".getBytes());
+ final int numSessions = 50;
+ final int rounds = 300;
+ final AtomicReference<Throwable> readerFailure = new
AtomicReference<>();
+ final AtomicBoolean stop = new AtomicBoolean(false);
+
+ try {
+ for (int i = 0; i < numSessions; i++) {
+ store.put(new Windowed<>(k, new SessionWindow(i * 100L, i *
100L + 10L)), "gen0".getBytes());
+ }
+ store.commit(Map.of());
+
+ final Thread reader = new Thread(() -> {
+ final ReadOnlySessionStore<Bytes, byte[]> committed =
store.readOnly(IsolationLevel.READ_COMMITTED);
+ try {
+ while (!stop.get()) {
+ try (KeyValueIterator<Windowed<Bytes>, byte[]> it =
+ committed.findSessions(k, 0L, numSessions *
100L)) {
+ String generation = null;
+ int count = 0;
+ while (it.hasNext()) {
+ final String value = new
String(it.next().value);
+ if (generation == null) {
+ generation = value;
+ } else {
+ assertEquals(generation, value, "scan
observed a partial commit");
+ }
+ count++;
+ }
+ assertEquals(numSessions, count, "scan observed a
partial commit");
+ }
+ }
+ } catch (final Throwable t) {
+ readerFailure.set(t);
+ }
+ }, "iq-reader");
+ reader.start();
+
+ for (int round = 1; round <= rounds && readerFailure.get() ==
null; round++) {
+ final String generation = "gen" + round;
+ for (int i = 0; i < numSessions; i++) {
+ store.put(new Windowed<>(k, new SessionWindow(i * 100L, i
* 100L + 10L)), generation.getBytes());
+ }
+ store.commit(Map.of());
+ }
+ stop.set(true);
+ reader.join(TimeUnit.SECONDS.toMillis(30));
+
+ if (readerFailure.get() != null) {
+ throw new AssertionError("READ_COMMITTED findSessions raced a
concurrent commit", readerFailure.get());
+ }
+ } finally {
+ stop.set(true);
+ store.close();
+ }
+ }
+
+ private InMemorySessionStore openTransactionalSessionStore() {
+ 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 InMemorySessionStore store = new InMemorySessionStore(
+ "txn-in-memory-session-store", RETENTION_PERIOD, "scope");
+ store.init(ctx, store);
+ return store;
+ }
+
}
\ No newline at end of file
diff --git
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryWindowStoreTest.java
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryWindowStoreTest.java
index b9461f75d05..be6981ca9f5 100644
---
a/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryWindowStoreTest.java
+++
b/streams/src/test/java/org/apache/kafka/streams/state/internals/InMemoryWindowStoreTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.kafka.streams.state.internals;
+import org.apache.kafka.common.IsolationLevel;
import org.apache.kafka.common.header.internals.RecordHeaders;
import org.apache.kafka.common.serialization.Serde;
import org.apache.kafka.common.serialization.Serdes;
@@ -31,6 +32,7 @@ import org.apache.kafka.streams.query.QueryResult;
import org.apache.kafka.streams.query.WindowKeyQuery;
import org.apache.kafka.streams.query.WindowRangeQuery;
import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.streams.state.ReadOnlyWindowStore;
import org.apache.kafka.streams.state.StateSerdes;
import org.apache.kafka.streams.state.Stores;
import org.apache.kafka.streams.state.TimestampedWindowStoreWithHeaders;
@@ -52,14 +54,19 @@ import java.time.Instant;
import java.util.LinkedList;
import java.util.List;
import java.util.Properties;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
import static java.time.Duration.ofMillis;
import static org.apache.kafka.common.utils.Utils.mkEntry;
import static org.apache.kafka.common.utils.Utils.mkMap;
import static
org.apache.kafka.streams.state.internals.WindowKeySchema.toStoreKeyBinary;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -253,6 +260,170 @@ public class InMemoryWindowStoreTest extends
AbstractWindowBytesStoreTest {
assertEquals(expected, actual);
}
+ @Test
+ public void readOnlyCommittedShouldHideStagedFetchValue() {
+ final InMemoryWindowStore txnStore = openTransactionalWindowStore();
+ try {
+ final Bytes key = Bytes.wrap("k".getBytes());
+ txnStore.put(key, "v1".getBytes(), 0L);
+ txnStore.commit(java.util.Map.of());
+
+ txnStore.put(key, "v2".getBytes(), WINDOW_SIZE);
+
+ final ReadOnlyWindowStore<Bytes, byte[]> uncommitted =
txnStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+ final ReadOnlyWindowStore<Bytes, byte[]> committed =
txnStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+ assertArrayEquals("v1".getBytes(), uncommitted.fetch(key, 0L));
+ assertArrayEquals("v2".getBytes(), uncommitted.fetch(key,
WINDOW_SIZE));
+ assertArrayEquals("v1".getBytes(), committed.fetch(key, 0L));
+ assertNull(committed.fetch(key, WINDOW_SIZE));
+ } finally {
+ txnStore.close();
+ }
+ }
+
+ @Test
+ public void readOnlyFetchRangeShouldRespectIsolationLevel() {
+ final InMemoryWindowStore txnStore = openTransactionalWindowStore();
+ try {
+ final Bytes key = Bytes.wrap("k".getBytes());
+ txnStore.put(key, "v1".getBytes(), 0L);
+ txnStore.commit(java.util.Map.of());
+ txnStore.put(key, "v2".getBytes(), WINDOW_SIZE);
+
+ final ReadOnlyWindowStore<Bytes, byte[]> uncommitted =
txnStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+ final ReadOnlyWindowStore<Bytes, byte[]> committed =
txnStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+ try (WindowStoreIterator<byte[]> it = uncommitted.fetch(key,
Instant.ofEpochMilli(0), Instant.ofEpochMilli(WINDOW_SIZE))) {
+ final List<String> values = new LinkedList<>();
+ while (it.hasNext()) {
+ values.add(new String(it.next().value));
+ }
+ assertEquals(List.of("v1", "v2"), values);
+ }
+ try (WindowStoreIterator<byte[]> it = committed.fetch(key,
Instant.ofEpochMilli(0), Instant.ofEpochMilli(WINDOW_SIZE))) {
+ final List<String> values = new LinkedList<>();
+ while (it.hasNext()) {
+ values.add(new String(it.next().value));
+ }
+ assertEquals(List.of("v1"), values);
+ }
+ } finally {
+ txnStore.close();
+ }
+ }
+
+ @Test
+ public void readOnlyAllShouldRespectIsolationLevel() {
+ final InMemoryWindowStore txnStore = openTransactionalWindowStore();
+ try {
+ txnStore.put(Bytes.wrap("k1".getBytes()), "v1".getBytes(), 0L);
+ txnStore.commit(java.util.Map.of());
+ txnStore.put(Bytes.wrap("k2".getBytes()), "v2".getBytes(),
WINDOW_SIZE);
+
+ try (KeyValueIterator<Windowed<Bytes>, byte[]> it =
+ txnStore.readOnly(IsolationLevel.READ_UNCOMMITTED).all())
{
+ int count = 0;
+ while (it.hasNext()) {
+ it.next();
+ count++;
+ }
+ assertEquals(2, count);
+ }
+ try (KeyValueIterator<Windowed<Bytes>, byte[]> it =
+ txnStore.readOnly(IsolationLevel.READ_COMMITTED).all()) {
+ int count = 0;
+ while (it.hasNext()) {
+ it.next();
+ count++;
+ }
+ assertEquals(1, count);
+ }
+ } finally {
+ txnStore.close();
+ }
+ }
+
+ @Test
+ public void readOnlyCommittedFetchAllShouldNotRaceConcurrentCommit()
throws InterruptedException {
+ // A READ_COMMITTED scan from a non-owner (IQ) thread must observe a
point-in-time snapshot of
+ // the committed segments, taken under the buffer's snapshot
read-lock. Without it the view
+ // iterated the live segment map while commit()'s write-lock-guarded
flushToBase() mutated it,
+ // exposing a half-applied commit. Each commit flips every key to a
single generation, so a
+ // correct snapshot scan must see one uniform generation across the
full key set.
+ final InMemoryWindowStore txnStore = openTransactionalWindowStore();
+ final int numKeys = 300;
+ final int rounds = 300;
+ final AtomicReference<Throwable> readerFailure = new
AtomicReference<>();
+ final AtomicBoolean stop = new AtomicBoolean(false);
+
+ try {
+ for (int i = 0; i < numKeys; i++) {
+ txnStore.put(Bytes.wrap(("k" + i).getBytes()),
"gen0".getBytes(), 0L);
+ }
+ txnStore.commit(java.util.Map.of());
+
+ final Thread reader = new Thread(() -> {
+ final ReadOnlyWindowStore<Bytes, byte[]> committed =
txnStore.readOnly(IsolationLevel.READ_COMMITTED);
+ try {
+ while (!stop.get()) {
+ try (KeyValueIterator<Windowed<Bytes>, byte[]> it =
+ committed.fetchAll(Instant.ofEpochMilli(0),
Instant.ofEpochMilli(RETENTION_PERIOD))) {
+ String generation = null;
+ int count = 0;
+ while (it.hasNext()) {
+ final String value = new
String(it.next().value);
+ if (generation == null) {
+ generation = value;
+ } else {
+ assertEquals(generation, value, "scan
observed a partial commit");
+ }
+ count++;
+ }
+ assertEquals(numKeys, count, "scan observed a
partial commit");
+ }
+ }
+ } catch (final Throwable t) {
+ readerFailure.set(t);
+ }
+ }, "iq-reader");
+ reader.start();
+
+ for (int round = 1; round <= rounds && readerFailure.get() ==
null; round++) {
+ final String generation = "gen" + round;
+ for (int i = 0; i < numKeys; i++) {
+ txnStore.put(Bytes.wrap(("k" + i).getBytes()),
generation.getBytes(), 0L);
+ }
+ txnStore.commit(java.util.Map.of());
+ }
+ stop.set(true);
+ reader.join(TimeUnit.SECONDS.toMillis(30));
+
+ if (readerFailure.get() != null) {
+ throw new AssertionError("READ_COMMITTED fetchAll raced a
concurrent commit", readerFailure.get());
+ }
+ } finally {
+ stop.set(true);
+ txnStore.close();
+ }
+ }
+
+ private InMemoryWindowStore openTransactionalWindowStore() {
+ 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 InMemoryWindowStore store = new InMemoryWindowStore(
+ "txn-in-memory-window-store", RETENTION_PERIOD, WINDOW_SIZE,
false, "scope");
+ store.init(ctx, store);
+ return store;
+ }
+
@Nested
class InMemoryStoreIQv2Tests {
private static final long WINDOW_SIZE = 10_000L;