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 8cfdfaaacc9 KAFKA-20500: Add isolation-level reads to versioned stores 
(#22682)
8cfdfaaacc9 is described below

commit 8cfdfaaacc90bd7c62c38253931b9c3f7d3593b5
Author: Nick Telford <[email protected]>
AuthorDate: Tue Jun 30 00:16:03 2026 +0100

    KAFKA-20500: Add isolation-level reads to versioned stores (#22682)
    
    Part of the KIP-892 interactive-query isolation-level series. Versioned
    stores are queryable through IQv2 (`VersionedKeyQuery`,
    `MultiVersionedKeyQuery`) and IQv1, but had no way to honour the
    configured isolation level. When the underlying `RocksDBStore` is
    transactional its accessor consults the staged-write buffer, so a
    `READ_COMMITTED` query would incorrectly observe writes still in the
    current transaction.
    
    This extends the `readOnly(IsolationLevel)` hook the other store
    families already have to versioned stores. Because versioned stores have
    no dedicated `ReadOnly*` parent interface, the default is added directly
    on `VersionedKeyValueStore`, and `VersionedBytesStore.readOnly` is
    covariantly narrowed so wrapper layers keep the versioned read methods.
    Reads in `RocksDBVersionedStore` — single-key latest, point-in-time, and
    timestamp-range — flow through `LogicalKeyValueSegment` views bound to a
    specific `DBAccessor`, so `READ_COMMITTED` bypasses the transaction
    buffer via the direct accessor. The metered and change-logging versioned
    wrappers gain matching overrides, and `StoreQueryUtils` dispatches
    versioned key queries through `readOnly(isolationLevel)`.
    
    Semantic tests assert that `READ_COMMITTED` hides staged writes while
    `READ_UNCOMMITTED` exposes them across the single-key, point-in-time,
    and timestamp-range read paths.
    
    This branched off the now-merged RocksDB isolation-level read work
    (KAFKA-20498) and now applies directly to trunk.
    
    
    Reviewers: Bill Bejeck <[email protected]>
---
 .../kafka/streams/state/VersionedBytesStore.java   |  11 ++
 .../streams/state/VersionedKeyValueStore.java      |  13 +++
 .../ChangeLoggingVersionedKeyValueBytesStore.java  |   6 +
 .../state/internals/LogicalKeyValueSegment.java    |  44 +++++++-
 .../internals/MeteredVersionedKeyValueStore.java   |  90 +++++++++++++++
 .../streams/state/internals/RocksDBStore.java      |  41 +++++--
 .../state/internals/RocksDBVersionedStore.java     | 124 +++++++++++++++++++--
 .../streams/state/internals/StoreQueryUtils.java   |   5 +-
 .../VersionedKeyValueToBytesStoreAdapter.java      |   6 +
 .../state/internals/RocksDBVersionedStoreTest.java |  84 ++++++++++++++
 10 files changed, 401 insertions(+), 23 deletions(-)

diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/VersionedBytesStore.java 
b/streams/src/main/java/org/apache/kafka/streams/state/VersionedBytesStore.java
index e840824577e..b1d7540437d 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/VersionedBytesStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/VersionedBytesStore.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.streams.state;
 
+import org.apache.kafka.common.IsolationLevel;
 import org.apache.kafka.common.utils.Bytes;
 
 /**
@@ -38,4 +39,14 @@ public interface VersionedBytesStore extends 
KeyValueStore<Bytes, byte[]>, Times
      * The analog of {@link VersionedKeyValueStore#delete(Object, long)}.
      */
     byte[] delete(Bytes key, long timestamp);
+
+    /**
+     * Return a read-only view of this store bound to the given {@link 
IsolationLevel}.
+     * Covariantly narrows {@link KeyValueStore#readOnly(IsolationLevel)} so 
callers retain
+     * access to the versioned read methods ({@link #get(Bytes, long)} and 
friends).
+     */
+    @Override
+    default VersionedBytesStore readOnly(final IsolationLevel isolationLevel) {
+        return this;
+    }
 }
\ No newline at end of file
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/VersionedKeyValueStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/VersionedKeyValueStore.java
index 40faaf003d3..ebd4fc7e34d 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/VersionedKeyValueStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/VersionedKeyValueStore.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.streams.state;
 
+import org.apache.kafka.common.IsolationLevel;
 import org.apache.kafka.streams.errors.InvalidStateStoreException;
 import org.apache.kafka.streams.processor.StateStore;
 
@@ -134,4 +135,16 @@ public interface VersionedKeyValueStore<K, V> extends 
StateStore {
      * @throws InvalidStateStoreException if the store is not initialized
      */
     VersionedRecord<V> get(K key, long asOfTimestamp);
+
+    /**
+     * Return a read-only view of this store bound to the given {@link 
IsolationLevel}.
+     * See {@link ReadOnlyKeyValueStore#readOnly(IsolationLevel)} for 
semantics.
+     * <p>
+     * Unlike the other store families, versioned stores have no dedicated 
{@code ReadOnly*}
+     * parent interface — the view is just another {@code 
VersionedKeyValueStore} whose mutating
+     * operations should not be invoked from interactive-query threads.
+     */
+    default VersionedKeyValueStore<K, V> readOnly(final IsolationLevel 
isolationLevel) {
+        return this;
+    }
 }
\ No newline at end of file
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingVersionedKeyValueBytesStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingVersionedKeyValueBytesStore.java
index bd35648210d..a391dcee8c0 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingVersionedKeyValueBytesStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/ChangeLoggingVersionedKeyValueBytesStore.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.streams.state.internals;
 
+import org.apache.kafka.common.IsolationLevel;
 import org.apache.kafka.common.header.Headers;
 import org.apache.kafka.common.header.internals.RecordHeaders;
 import org.apache.kafka.common.utils.Bytes;
@@ -57,6 +58,11 @@ public class ChangeLoggingVersionedKeyValueBytesStore 
extends ChangeLoggingKeyVa
         return oldValue;
     }
 
+    @Override
+    public VersionedBytesStore readOnly(final IsolationLevel isolationLevel) {
+        return inner.readOnly(isolationLevel);
+    }
+
     @Override public void log(final Bytes key, final byte[] value, final long 
timestamp, final Headers headers) {
         internalContext.logChange(
             name(),
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/LogicalKeyValueSegment.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/LogicalKeyValueSegment.java
index ba6215ecca0..2aa5182fc2d 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/LogicalKeyValueSegment.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/LogicalKeyValueSegment.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.streams.state.internals;
 
+import org.apache.kafka.common.IsolationLevel;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.serialization.BytesSerializer;
 import org.apache.kafka.common.utils.Bytes;
@@ -60,19 +61,45 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
     private final String name;
     private final RocksDBStore physicalStore;
     private final PrefixKeyFormatter prefixKeyFormatter;
+    // Non-null for read-only views produced by {@link 
#readOnly(IsolationLevel)}: all reads go
+    // through this accessor (bypassing any transaction buffer for 
READ_COMMITTED); writes are
+    // disallowed. Null for regular segments, which use the physicalStore's 
current accessor.
+    private final RocksDBStore.DBAccessor readAccessor;
 
     final Set<KeyValueIterator<Bytes, byte[]>> openIterators = 
Collections.synchronizedSet(new HashSet<>());
 
     LogicalKeyValueSegment(final long id,
                            final String name,
                            final RocksDBStore physicalStore) {
+        this(id, name, physicalStore, null);
+    }
+
+    private LogicalKeyValueSegment(final long id,
+                                   final String name,
+                                   final RocksDBStore physicalStore,
+                                   final RocksDBStore.DBAccessor readAccessor) 
{
         this.id = id;
         this.name = name;
         this.physicalStore = Objects.requireNonNull(physicalStore);
-
+        this.readAccessor = readAccessor;
         this.prefixKeyFormatter = new 
PrefixKeyFormatter(serializeLongToBytes(id));
     }
 
+    /**
+     * Returns a read-only view of this segment bound to the given isolation 
level. Reads go
+     * through the accessor appropriate for {@code level}; mutating calls 
throw.
+     */
+    @Override
+    public LogicalKeyValueSegment readOnly(final IsolationLevel level) {
+        return new LogicalKeyValueSegment(id, name, physicalStore, 
physicalStore.dbAccessor.readOnly(level));
+    }
+
+    private void rejectIfReadOnly() {
+        if (readAccessor != null) {
+            throw new UnsupportedOperationException("Write operations are not 
supported on a read-only segment view");
+        }
+    }
+
     @Override
     public long id() {
         return id;
@@ -80,6 +107,7 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
 
     @Override
     public synchronized void destroy() {
+        rejectIfReadOnly();
         if (id < 0) {
             throw new IllegalStateException("Negative segment ID indicates a 
reserved segment, "
                 + "which should not be destroyed. Reserved segments are 
cleaned up only when "
@@ -95,6 +123,7 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
 
     @Override
     public synchronized void deleteRange(final Bytes keyFrom, final Bytes 
keyTo) {
+        rejectIfReadOnly();
         physicalStore.deleteRange(
             prefixKeyFormatter.addPrefix(keyFrom),
             prefixKeyFormatter.addPrefix(keyTo));
@@ -102,6 +131,7 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
 
     @Override
     public synchronized void put(final Bytes key, final byte[] value) {
+        rejectIfReadOnly();
         physicalStore.put(
             prefixKeyFormatter.addPrefix(key),
             value);
@@ -109,6 +139,7 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
 
     @Override
     public synchronized byte[] putIfAbsent(final Bytes key, final byte[] 
value) {
+        rejectIfReadOnly();
         return physicalStore.putIfAbsent(
             prefixKeyFormatter.addPrefix(key),
             value);
@@ -116,6 +147,7 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
 
     @Override
     public synchronized void putAll(final List<KeyValue<Bytes, byte[]>> 
entries) {
+        rejectIfReadOnly();
         physicalStore.putAll(entries.stream()
             .map(kv -> new KeyValue<>(
                 prefixKeyFormatter.addPrefix(kv.key),
@@ -125,6 +157,7 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
 
     @Override
     public synchronized byte[] delete(final Bytes key) {
+        rejectIfReadOnly();
         return physicalStore.delete(prefixKeyFormatter.addPrefix(key));
     }
 
@@ -184,13 +217,18 @@ public class LogicalKeyValueSegment implements Segment, 
VersionedStoreSegment {
     }
 
     private synchronized byte[] get(final Bytes key, final Optional<Snapshot> 
snapshot) {
+        final Bytes prefixed = prefixKeyFormatter.addPrefix(key);
         if (snapshot.isPresent()) {
             try (ReadOptions readOptions = new ReadOptions()) {
                 readOptions.setSnapshot(snapshot.get());
-                return physicalStore.get(prefixKeyFormatter.addPrefix(key), 
readOptions);
+                return readAccessor == null
+                    ? physicalStore.get(prefixed, readOptions)
+                    : physicalStore.get(prefixed, readOptions, readAccessor);
             }
         } else {
-            return physicalStore.get(prefixKeyFormatter.addPrefix(key));
+            return readAccessor == null
+                ? physicalStore.get(prefixed)
+                : physicalStore.get(prefixed, readAccessor);
         }
     }
 
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredVersionedKeyValueStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredVersionedKeyValueStore.java
index 9cd8b9e1861..2f6f5380c1a 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredVersionedKeyValueStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/MeteredVersionedKeyValueStore.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.streams.state.internals;
 
+import org.apache.kafka.common.IsolationLevel;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.serialization.Serde;
 import org.apache.kafka.common.utils.Bytes;
@@ -403,4 +404,93 @@ public class MeteredVersionedKeyValueStore<K, V>
     public Position getPosition() {
         return internal.getPosition();
     }
+
+    @Override
+    public VersionedKeyValueStore<K, V> readOnly(final IsolationLevel 
isolationLevel) {
+        return new ReadOnlyView(wrapped().readOnly(isolationLevel));
+    }
+
+    /**
+     * Read-only view that re-applies this store's serdes and get sensor on 
top of a configurable
+     * underlying {@link VersionedBytesStore}. Used so that IQ reads at a 
given isolation level
+     * preserve the Metered layer's metrics and (de)serialisation behaviour. 
Mutating operations
+     * throw; lifecycle methods that only report state delegate to the 
enclosing store.
+     */
+    private final class ReadOnlyView implements VersionedKeyValueStore<K, V> {
+
+        private final VersionedBytesStore underlying;
+
+        ReadOnlyView(final VersionedBytesStore underlying) {
+            this.underlying = underlying;
+        }
+
+        @Override
+        public VersionedRecord<V> get(final K key) {
+            Objects.requireNonNull(key, "key cannot be null");
+            try {
+                final ValueAndTimestamp<V> valueAndTimestamp = 
maybeMeasureLatency(
+                    () -> 
internal.deserializeValue(underlying.get(internal.serializeKey(key))),
+                    internal.time,
+                    internal.getSensor
+                );
+                return valueAndTimestamp == null
+                    ? null
+                    : new VersionedRecord<>(valueAndTimestamp.value(), 
valueAndTimestamp.timestamp());
+            } catch (final ProcessorStateException e) {
+                throw new 
ProcessorStateException(String.format(e.getMessage(), key), e);
+            }
+        }
+
+        @Override
+        public VersionedRecord<V> get(final K key, final long asOfTimestamp) {
+            Objects.requireNonNull(key, "key cannot be null");
+            try {
+                final ValueAndTimestamp<V> valueAndTimestamp = 
maybeMeasureLatency(
+                    () -> 
internal.deserializeValue(underlying.get(internal.serializeKey(key), 
asOfTimestamp)),
+                    internal.time,
+                    internal.getSensor
+                );
+                return valueAndTimestamp == null
+                    ? null
+                    : new VersionedRecord<>(valueAndTimestamp.value(), 
valueAndTimestamp.timestamp());
+            } catch (final ProcessorStateException e) {
+                throw new 
ProcessorStateException(String.format(e.getMessage(), key), e);
+            }
+        }
+
+        @Override
+        public long put(final K key, final V value, final long timestamp) {
+            throw new UnsupportedOperationException("put is not supported on a 
read-only view");
+        }
+
+        @Override
+        public VersionedRecord<V> delete(final K key, final long timestamp) {
+            throw new UnsupportedOperationException("delete is not supported 
on a read-only view");
+        }
+
+        @Override
+        public String name() {
+            return MeteredVersionedKeyValueStore.this.name();
+        }
+
+        @Override
+        public void init(final StateStoreContext stateStoreContext, final 
StateStore root) {
+            throw new UnsupportedOperationException("init is not supported on 
a read-only view");
+        }
+
+        @Override
+        public void close() {
+            throw new UnsupportedOperationException("close is not supported on 
a read-only view");
+        }
+
+        @Override
+        public boolean persistent() {
+            return MeteredVersionedKeyValueStore.this.persistent();
+        }
+
+        @Override
+        public boolean isOpen() {
+            return MeteredVersionedKeyValueStore.this.isOpen();
+        }
+    }
 }
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java
index 8b16da8003c..417c36e8547 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java
@@ -759,14 +759,26 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
 
     @Override
     public ReadOnlyKeyValueStore<Bytes, byte[]> readOnly(final IsolationLevel 
isolationLevel) {
-        Objects.requireNonNull(isolationLevel, "isolationLevel cannot be 
null");
-        final DBAccessor viewAccessor;
-        if (isolationLevel == IsolationLevel.READ_COMMITTED && dbAccessor 
instanceof TransactionalDBAccessor) {
-            viewAccessor = ((TransactionalDBAccessor) dbAccessor).underlying;
-        } else {
-            viewAccessor = dbAccessor;
+        return new ReadOnlyView(dbAccessor.readOnly(isolationLevel));
+    }
+
+    // Read helpers for isolation-level views that sit above this store (e.g. 
LogicalKeyValueSegment.readOnly).
+    byte[] get(final Bytes key, final DBAccessor accessor) {
+        validateStoreOpen();
+        try {
+            return cfAccessor.get(accessor, key.get());
+        } catch (final RocksDBException e) {
+            throw new ProcessorStateException("Error while getting value for 
key from store " + name, e);
+        }
+    }
+
+    byte[] get(final Bytes key, final ReadOptions readOptions, final 
DBAccessor accessor) {
+        validateStoreOpen();
+        try {
+            return cfAccessor.get(accessor, key.get(), readOptions);
+        } catch (final RocksDBException e) {
+            throw new ProcessorStateException("Error while getting value for 
key from store " + name, e);
         }
-        return new ReadOnlyView(viewAccessor);
     }
 
     /**
@@ -1065,6 +1077,11 @@ public class RocksDBStore implements 
KeyValueStore<Bytes, byte[]>, BatchWritingS
         void reset();
         void close();
 
+        default DBAccessor readOnly(final IsolationLevel isolationLevel) {
+            Objects.requireNonNull(isolationLevel, "isolationLevel cannot be 
null");
+            return this;
+        }
+
         default ManagedKeyValueIterator<Bytes, byte[]> all(final 
ColumnFamilyHandle cf, final String storeName, final boolean forward) {
             final RocksIterator iter = newIterator(cf);
             if (forward) {
@@ -1258,6 +1275,16 @@ public class RocksDBStore implements 
KeyValueStore<Bytes, byte[]>, BatchWritingS
             underlying.close();
         }
 
+        @Override
+        public DBAccessor readOnly(final IsolationLevel isolationLevel) {
+            Objects.requireNonNull(isolationLevel, "isolationLevel cannot be 
null");
+            if (isolationLevel == IsolationLevel.READ_COMMITTED) {
+                return underlying;
+            } else {
+                return this;
+            }
+        }
+
         @Override
         public ManagedKeyValueIterator<Bytes, byte[]> all(final 
ColumnFamilyHandle cf, final String storeName, final boolean forward) {
             return buffer.all(cf, forward);
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStore.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStore.java
index c4c26d047f5..6477427e66f 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStore.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStore.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;
@@ -183,11 +184,15 @@ public class RocksDBVersionedStore implements 
VersionedKeyValueStore<Bytes, byte
 
     @Override
     public VersionedRecord<byte[]> get(final Bytes key) {
+        return get(key, IsolationLevel.READ_UNCOMMITTED);
+    }
+
+    private VersionedRecord<byte[]> get(final Bytes key, final IsolationLevel 
level) {
         Objects.requireNonNull(key, "key cannot be null");
         validateStoreOpen();
 
         // latest value (if present) is guaranteed to be in the latest value 
store
-        final byte[] rawLatestValueAndTimestamp = latestValueStore.get(key);
+        final byte[] rawLatestValueAndTimestamp = 
latestValueStore(level).get(key);
         if (rawLatestValueAndTimestamp != null) {
             return new VersionedRecord<>(
                 LatestValueFormatter.value(rawLatestValueAndTimestamp),
@@ -200,14 +205,20 @@ public class RocksDBVersionedStore implements 
VersionedKeyValueStore<Bytes, byte
 
     @Override
     public VersionedRecord<byte[]> get(final Bytes key, final long 
asOfTimestamp) {
+        return get(key, asOfTimestamp, IsolationLevel.READ_UNCOMMITTED);
+    }
+
+    private VersionedRecord<byte[]> get(final Bytes key, final long 
asOfTimestamp, final IsolationLevel level) {
         Objects.requireNonNull(key, "key cannot be null");
         validateStoreOpen();
 
+        final LogicalKeyValueSegment latestView = latestValueStore(level);
+
         if (asOfTimestamp < observedStreamTime - historyRetention) {
             // history retention exceeded. we still check the latest value 
store in case the
             // latest record version satisfies the timestamp bound, in which 
case it should
             // still be returned (i.e., the latest record version per key 
never expires).
-            final byte[] rawLatestValueAndTimestamp = 
latestValueStore.get(key);
+            final byte[] rawLatestValueAndTimestamp = latestView.get(key);
             if (rawLatestValueAndTimestamp != null) {
                 final long latestTimestamp = 
LatestValueFormatter.timestamp(rawLatestValueAndTimestamp);
                 if (latestTimestamp <= asOfTimestamp) {
@@ -227,7 +238,7 @@ public class RocksDBVersionedStore implements 
VersionedKeyValueStore<Bytes, byte
         }
 
         // first check the latest value store
-        final byte[] rawLatestValueAndTimestamp = latestValueStore.get(key);
+        final byte[] rawLatestValueAndTimestamp = latestView.get(key);
         if (rawLatestValueAndTimestamp != null) {
             final long latestTimestamp = 
LatestValueFormatter.timestamp(rawLatestValueAndTimestamp);
             if (latestTimestamp <= asOfTimestamp) {
@@ -236,8 +247,7 @@ public class RocksDBVersionedStore implements 
VersionedKeyValueStore<Bytes, byte
         }
 
         // check segment stores
-        final List<LogicalKeyValueSegment> segments = 
segmentStores.segments(asOfTimestamp, Long.MAX_VALUE, false);
-        for (final LogicalKeyValueSegment segment : segments) {
+        for (final LogicalKeyValueSegment segment : 
viewSegments(segmentStores.segments(asOfTimestamp, Long.MAX_VALUE, false), 
level)) {
             final byte[] rawSegmentValue = segment.get(key);
             if (rawSegmentValue != null) {
                 final long nextTs = 
RocksDBVersionedStoreSegmentValueFormatter.nextTimestamp(rawSegmentValue);
@@ -272,31 +282,123 @@ public class RocksDBVersionedStore implements 
VersionedKeyValueStore<Bytes, byte
         return null;
     }
 
-    @SuppressWarnings("unchecked")
     VersionedRecordIterator<byte[]> get(final Bytes key, final long 
fromTimestamp, final long toTimestamp, final ResultOrder order) {
+        return get(key, fromTimestamp, toTimestamp, order, 
IsolationLevel.READ_UNCOMMITTED);
+    }
+
+    VersionedRecordIterator<byte[]> get(final Bytes key, final long 
fromTimestamp, final long toTimestamp,
+                                        final ResultOrder order, final 
IsolationLevel level) {
         validateStoreOpen();
 
+        final LogicalKeyValueSegment latestView = latestValueStore(level);
+
         if (toTimestamp < observedStreamTime - historyRetention) {
             // history retention exceeded. we still check the latest value 
store in case the
             // latest record version satisfies the timestamp bound, in which 
case it should
             // still be returned (i.e., the latest record version per key 
never expires).
-            return new 
LogicalSegmentIterator(Collections.singletonList(latestValueStore).listIterator(),
 key, fromTimestamp, toTimestamp, order);
+            return new 
LogicalSegmentIterator(Collections.singletonList(latestView).listIterator(), 
key, fromTimestamp, toTimestamp, order);
         } else {
             final List<LogicalKeyValueSegment> segments = new ArrayList<>();
             // add segment stores
             // consider the search lower bound as -INF (LONG.MIN_VALUE) to 
find the record that has been inserted before the {@code fromTimestamp}
             // but is still valid in query specified time interval.
             if (order.equals(ResultOrder.ASCENDING)) {
-                segments.addAll(segmentStores.segments(Long.MIN_VALUE, 
toTimestamp, true));
-                segments.add(latestValueStore);
+                
segments.addAll(viewSegments(segmentStores.segments(Long.MIN_VALUE, 
toTimestamp, true), level));
+                segments.add(latestView);
             } else {
-                segments.add(latestValueStore);
-                segments.addAll(segmentStores.segments(Long.MIN_VALUE, 
toTimestamp, false));
+                segments.add(latestView);
+                
segments.addAll(viewSegments(segmentStores.segments(Long.MIN_VALUE, 
toTimestamp, false), level));
             }
             return new LogicalSegmentIterator(segments.listIterator(), key, 
fromTimestamp, toTimestamp, order);
         }
     }
 
+    private LogicalKeyValueSegment latestValueStore(final IsolationLevel 
level) {
+        return level == IsolationLevel.READ_UNCOMMITTED ? latestValueStore : 
latestValueStore.readOnly(level);
+    }
+
+    private static List<LogicalKeyValueSegment> viewSegments(final 
List<LogicalKeyValueSegment> segments,
+                                                             final 
IsolationLevel level) {
+        if (level == IsolationLevel.READ_UNCOMMITTED) {
+            return segments;
+        }
+        final List<LogicalKeyValueSegment> views = new 
ArrayList<>(segments.size());
+        for (final LogicalKeyValueSegment segment : segments) {
+            views.add(segment.readOnly(level));
+        }
+        return views;
+    }
+
+    @Override
+    public VersionedKeyValueStore<Bytes, byte[]> readOnly(final IsolationLevel 
isolationLevel) {
+        Objects.requireNonNull(isolationLevel, "isolationLevel cannot be 
null");
+        if (isolationLevel == IsolationLevel.READ_UNCOMMITTED) {
+            return this;
+        }
+        return new ReadOnlyView(isolationLevel);
+    }
+
+    /**
+     * Read-only view of this store bound to an isolation level. Read methods 
delegate to
+     * the private {@code get(...)} helpers passing the level; write methods 
and other
+     * lifecycle operations throw or are no-ops, since the view is only used 
by IQ.
+     */
+    private final class ReadOnlyView implements VersionedKeyValueStore<Bytes, 
byte[]> {
+
+        private final IsolationLevel level;
+
+        ReadOnlyView(final IsolationLevel level) {
+            this.level = level;
+        }
+
+        @Override
+        public VersionedRecord<byte[]> get(final Bytes key) {
+            return RocksDBVersionedStore.this.get(key, level);
+        }
+
+        @Override
+        public VersionedRecord<byte[]> get(final Bytes key, final long 
asOfTimestamp) {
+            return RocksDBVersionedStore.this.get(key, asOfTimestamp, level);
+        }
+
+        @Override
+        public long put(final Bytes key, final byte[] value, final long 
timestamp) {
+            throw new UnsupportedOperationException("put not supported on a 
read-only view");
+        }
+
+        @Override
+        public VersionedRecord<byte[]> delete(final Bytes key, final long 
timestamp) {
+            throw new UnsupportedOperationException("delete not supported on a 
read-only view");
+        }
+
+        @Override
+        public String name() {
+            return RocksDBVersionedStore.this.name();
+        }
+
+        @Override
+        public void init(final StateStoreContext stateStoreContext, final 
StateStore root) {
+            throw new UnsupportedOperationException("init not supported on a 
read-only view");
+        }
+
+        @SuppressWarnings("deprecation")
+        @Override
+        public void flush() { }
+
+        @Override
+        public void close() { }
+
+        @Override
+        public boolean persistent() {
+            return RocksDBVersionedStore.this.persistent();
+        }
+
+        @Override
+        public boolean isOpen() {
+            return RocksDBVersionedStore.this.isOpen();
+        }
+    }
+
     @Override
     public String name() {
         return name;
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/StoreQueryUtils.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/StoreQueryUtils.java
index ab23baa765b..c713c546d70 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/StoreQueryUtils.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/StoreQueryUtils.java
@@ -373,7 +373,7 @@ public final class StoreQueryUtils {
     ) {
         if (store instanceof VersionedKeyValueStore) {
             final VersionedKeyValueStore<Bytes, byte[]> versionedKeyValueStore 
=
-                (VersionedKeyValueStore<Bytes, byte[]>) store;
+                ((VersionedKeyValueStore<Bytes, byte[]>) 
store).readOnly(config.getIsolationLevel());
             final VersionedKeyQuery<Bytes, byte[]> rawKeyQuery =
                 (VersionedKeyQuery<Bytes, byte[]>) query;
             try {
@@ -413,7 +413,8 @@ public final class StoreQueryUtils {
                             rawKeyQuery.key(),
                             rawKeyQuery.fromTime().get().toEpochMilli(),
                             rawKeyQuery.toTime().get().toEpochMilli(),
-                            rawKeyQuery.resultOrder()
+                            rawKeyQuery.resultOrder(),
+                            config.getIsolationLevel()
                         );
                 return (QueryResult<R>) QueryResult.forResult(segmentIterator);
             } catch (final Exception e) {
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/VersionedKeyValueToBytesStoreAdapter.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/VersionedKeyValueToBytesStoreAdapter.java
index ee422bd837e..9b149fa904d 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/VersionedKeyValueToBytesStoreAdapter.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/VersionedKeyValueToBytesStoreAdapter.java
@@ -16,6 +16,7 @@
  */
 package org.apache.kafka.streams.state.internals;
 
+import org.apache.kafka.common.IsolationLevel;
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.serialization.Serde;
 import org.apache.kafka.common.serialization.Serdes.ByteArraySerde;
@@ -84,6 +85,11 @@ public class VersionedKeyValueToBytesStoreAdapter implements 
VersionedBytesStore
         return serializeAsBytes(versionedRecord);
     }
 
+    @Override
+    public VersionedBytesStore readOnly(final IsolationLevel isolationLevel) {
+        return new 
VersionedKeyValueToBytesStoreAdapter(inner.readOnly(isolationLevel));
+    }
+
     @Override
     public String name() {
         return inner.name();
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStoreTest.java
index 340ca7eb216..ad74da70d49 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStoreTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBVersionedStoreTest.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.Metric;
 import org.apache.kafka.common.MetricName;
 import org.apache.kafka.common.header.internals.RecordHeaders;
@@ -30,6 +31,7 @@ import org.apache.kafka.common.utils.Bytes;
 import org.apache.kafka.streams.StreamsConfig;
 import org.apache.kafka.streams.query.Position;
 import org.apache.kafka.streams.query.ResultOrder;
+import org.apache.kafka.streams.state.VersionedKeyValueStore;
 import org.apache.kafka.streams.state.VersionedRecord;
 import org.apache.kafka.streams.state.VersionedRecordIterator;
 import org.apache.kafka.test.InternalMockProcessorContext;
@@ -833,6 +835,88 @@ public class RocksDBVersionedStoreTest {
         verifyExpiredRecordSensor(1);
     }
 
+    @Test
+    public void readOnlyCommittedShouldHideStagedLatestPut() {
+        reopenWithTransactionalEOS();
+
+        putToStore("k", "v1", BASE_TIMESTAMP, 
PUT_RETURN_CODE_VALID_TO_UNDEFINED);
+        store.commit(Map.of());
+        putToStore("k", "v2", BASE_TIMESTAMP + 1, 
PUT_RETURN_CODE_VALID_TO_UNDEFINED);
+
+        final VersionedKeyValueStore<Bytes, byte[]> uncommitted = 
store.readOnly(IsolationLevel.READ_UNCOMMITTED);
+        final VersionedKeyValueStore<Bytes, byte[]> committed = 
store.readOnly(IsolationLevel.READ_COMMITTED);
+
+        final Bytes key = new Bytes(STRING_SERIALIZER.serialize(null, "k"));
+        final VersionedRecord<byte[]> uLatest = uncommitted.get(key);
+        assertThat(STRING_DESERIALIZER.deserialize(null, uLatest.value()), 
equalTo("v2"));
+        assertThat(uLatest.timestamp(), equalTo(BASE_TIMESTAMP + 1));
+
+        final VersionedRecord<byte[]> cLatest = committed.get(key);
+        assertThat(STRING_DESERIALIZER.deserialize(null, cLatest.value()), 
equalTo("v1"));
+        assertThat(cLatest.timestamp(), equalTo(BASE_TIMESTAMP));
+    }
+
+    @Test
+    public void readOnlyCommittedShouldHideStagedTimestampedPut() {
+        reopenWithTransactionalEOS();
+
+        putToStore("k", "v1", BASE_TIMESTAMP, 
PUT_RETURN_CODE_VALID_TO_UNDEFINED);
+        store.commit(Map.of());
+        putToStore("k", "v2", BASE_TIMESTAMP + 2, 
PUT_RETURN_CODE_VALID_TO_UNDEFINED);
+
+        final Bytes key = new Bytes(STRING_SERIALIZER.serialize(null, "k"));
+
+        final VersionedRecord<byte[]> uAtNew = 
store.readOnly(IsolationLevel.READ_UNCOMMITTED).get(key, BASE_TIMESTAMP + 2);
+        assertThat(STRING_DESERIALIZER.deserialize(null, uAtNew.value()), 
equalTo("v2"));
+
+        final VersionedRecord<byte[]> cAtNew = 
store.readOnly(IsolationLevel.READ_COMMITTED).get(key, BASE_TIMESTAMP + 2);
+        assertThat(STRING_DESERIALIZER.deserialize(null, cAtNew.value()), 
equalTo("v1"));
+    }
+
+    @Test
+    public void packagePrivateTimestampRangeGetShouldRespectIsolationLevel() {
+        reopenWithTransactionalEOS();
+
+        putToStore("k", "v1", BASE_TIMESTAMP, 
PUT_RETURN_CODE_VALID_TO_UNDEFINED);
+        store.commit(Map.of());
+        putToStore("k", "v2", BASE_TIMESTAMP + 2, 
PUT_RETURN_CODE_VALID_TO_UNDEFINED);
+
+        final Bytes key = new Bytes(STRING_SERIALIZER.serialize(null, "k"));
+
+        final List<String> uncommitted = new ArrayList<>();
+        try (VersionedRecordIterator<byte[]> it = store.get(
+                key, BASE_TIMESTAMP, BASE_TIMESTAMP + 2, 
ResultOrder.ASCENDING, IsolationLevel.READ_UNCOMMITTED)) {
+            while (it.hasNext()) {
+                uncommitted.add(STRING_DESERIALIZER.deserialize(null, 
it.next().value()));
+            }
+        }
+        final List<String> committed = new ArrayList<>();
+        try (VersionedRecordIterator<byte[]> it = store.get(
+                key, BASE_TIMESTAMP, BASE_TIMESTAMP + 2, 
ResultOrder.ASCENDING, IsolationLevel.READ_COMMITTED)) {
+            while (it.hasNext()) {
+                committed.add(STRING_DESERIALIZER.deserialize(null, 
it.next().value()));
+            }
+        }
+        assertThat(uncommitted, equalTo(List.of("v1", "v2")));
+        assertThat(committed, equalTo(List.of("v1")));
+    }
+
+    private void reopenWithTransactionalEOS() {
+        store.close();
+        final java.util.Properties props = StreamsTestUtils.getStreamsConfig();
+        props.setProperty(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, 
StreamsConfig.EXACTLY_ONCE_V2);
+        props.setProperty(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, 
"true");
+        context = new InternalMockProcessorContext<>(
+            TestUtils.tempDirectory(),
+            Serdes.String(),
+            Serdes.String(),
+            new StreamsConfig(props)
+        );
+        context.setTime(BASE_TIMESTAMP);
+        store = new RocksDBVersionedStore(STORE_NAME, METRICS_SCOPE, 
HISTORY_RETENTION, SEGMENT_INTERVAL);
+        store.init(context, store);
+    }
+
     @Test
     public void shouldMigrateExistingPositionFromFile() {
         final Position position = Position.fromMap(mkMap(mkEntry("topic", 
mkMap(mkEntry(0, 1L)))));


Reply via email to