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 81de9daf47c KAFKA-20498: Add isolation-level reads to RocksDBStore 
(#22653)
81de9daf47c is described below

commit 81de9daf47c7734c77669fd386f2f7a9f65e22c2
Author: Nick Telford <[email protected]>
AuthorDate: Thu Jun 25 19:05:43 2026 +0100

    KAFKA-20498: Add isolation-level reads to RocksDBStore (#22653)
    
    Interactive queries against a transactional RocksDB store (EOS) need to
    choose whether they observe writes staged in the current, uncommitted
    transaction. This adds a `readOnly(IsolationLevel)` override to
    `RocksDBStore` that returns a view bound to a specific `DBAccessor`:
    `READ_COMMITTED` reads through the underlying `DirectDBAccessor`,
    bypassing the transaction buffer, so the query sees only committed data;
    `READ_UNCOMMITTED` (and the default for non-transactional stores) uses
    the active accessor, preserving existing behaviour.
    
    The returned `ReadOnlyView` shares `cfAccessor` and `openIterators` with
    the store, so reads go through the same iterator-tracking path while the
    view exposes no writes.
    
    Includes `readOnly(IsolationLevel)` semantic tests covering `get`,
    `range`/`all`, and `prefixScan` that stage writes without committing and
    assert the `READ_COMMITTED`/`READ_UNCOMMITTED` divergence, reopening the
    store under an EOS context with `enable.transactional.statestores=true`
    to exercise the transaction buffer.
    
    Reviewers: Bill Bejeck <[email protected]>
---
 .../streams/state/internals/RocksDBStore.java      | 103 ++++++++++++
 .../streams/state/internals/RocksDBStoreTest.java  | 185 +++++++++++++++++++++
 2 files changed, 288 insertions(+)

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 e2fb110dcd1..7ed94b0f35a 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
@@ -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.RecordingLevel;
 import org.apache.kafka.common.serialization.Serializer;
@@ -41,6 +42,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.apache.kafka.streams.state.RocksDBConfigSetter;
 import org.apache.kafka.streams.state.internals.metrics.RocksDBMetricsRecorder;
 
@@ -746,6 +748,107 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
         return 0;
     }
 
+    @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(viewAccessor);
+    }
+
+    /**
+     * Read-only view of this store bound to a specific {@link DBAccessor}. 
Reads go through the
+     * chosen accessor so IQ callers can pick {@code READ_COMMITTED} (direct) 
or
+     * {@code READ_UNCOMMITTED} (through the transaction buffer) without 
affecting the
+     * processor-thread's view of the store.
+     */
+    private final class ReadOnlyView implements ReadOnlyKeyValueStore<Bytes, 
byte[]> {
+
+        private final DBAccessor viewAccessor;
+
+        ReadOnlyView(final DBAccessor viewAccessor) {
+            this.viewAccessor = viewAccessor;
+        }
+
+        @Override
+        public byte[] get(final Bytes key) {
+            Objects.requireNonNull(key, "key cannot be null");
+            validateStoreOpen();
+            try {
+                return cfAccessor.get(viewAccessor, key.get());
+            } catch (final RocksDBException e) {
+                throw new ProcessorStateException("Error while getting value 
for key from store " + name, e);
+            }
+        }
+
+        @Override
+        public KeyValueIterator<Bytes, byte[]> range(final Bytes from, final 
Bytes to) {
+            return doRange(from, to, true);
+        }
+
+        @Override
+        public KeyValueIterator<Bytes, byte[]> reverseRange(final Bytes from, 
final Bytes to) {
+            return doRange(from, to, false);
+        }
+
+        private KeyValueIterator<Bytes, byte[]> doRange(final Bytes from, 
final Bytes to, final boolean forward) {
+            if (Objects.nonNull(from) && Objects.nonNull(to) && 
from.compareTo(to) > 0) {
+                return KeyValueIterators.emptyIterator();
+            }
+            validateStoreOpen();
+            final ManagedKeyValueIterator<Bytes, byte[]> iter = 
cfAccessor.range(viewAccessor, from, to, forward);
+            openIterators.add(iter);
+            iter.onClose(() -> openIterators.remove(iter));
+            return iter;
+        }
+
+        @Override
+        public KeyValueIterator<Bytes, byte[]> all() {
+            return doAll(true);
+        }
+
+        @Override
+        public KeyValueIterator<Bytes, byte[]> reverseAll() {
+            return doAll(false);
+        }
+
+        private KeyValueIterator<Bytes, byte[]> doAll(final boolean forward) {
+            validateStoreOpen();
+            final ManagedKeyValueIterator<Bytes, byte[]> iter = 
cfAccessor.all(viewAccessor, forward);
+            openIterators.add(iter);
+            iter.onClose(() -> openIterators.remove(iter));
+            return iter;
+        }
+
+        @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");
+            validateStoreOpen();
+            final Bytes prefixBytes = 
Bytes.wrap(prefixKeySerializer.serialize(null, prefix));
+            final ManagedKeyValueIterator<Bytes, byte[]> iter = 
cfAccessor.prefixScan(viewAccessor, prefixBytes);
+            openIterators.add(iter);
+            iter.onClose(() -> openIterators.remove(iter));
+            return iter;
+        }
+
+        @Override
+        public long approximateNumEntries() {
+            validateStoreOpen();
+            try {
+                final long n = cfAccessor.approximateNumEntries(viewAccessor);
+                return isOverflowing(n) ? Long.MAX_VALUE : n;
+            } catch (final RocksDBException e) {
+                throw new ProcessorStateException("Error fetching property 
from store " + name, e);
+            }
+        }
+    }
+
     @Override
     public long approximateNumEntries() {
         validateStoreOpen();
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
index 7734646a5c2..454aac15e9b 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.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.TopicPartition;
@@ -54,6 +55,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.streams.state.RocksDBConfigSetter;
 import org.apache.kafka.streams.state.StoreBuilder;
 import org.apache.kafka.streams.state.Stores;
@@ -220,6 +222,13 @@ public class RocksDBStoreTest extends 
AbstractKeyValueStoreTest {
         return getProcessorContext(stateDir, streamsProps);
     }
 
+    private InternalMockProcessorContext<?, ?> 
getTransactionalEOSProcessorContext(final File stateDir) {
+        final Properties streamsProps = StreamsTestUtils.getStreamsConfig();
+        streamsProps.setProperty(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, 
StreamsConfig.EXACTLY_ONCE_V2);
+        
streamsProps.setProperty(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, 
"true");
+        return getProcessorContext(stateDir, streamsProps);
+    }
+
     @Test
     public void 
shouldAddValueProvidersWithoutStatisticsToInjectedMetricsRecorderWhenRecordingLevelInfo()
 {
         rocksDBStore = getRocksDBStoreWithRocksDBMetricsRecorder();
@@ -1334,6 +1343,182 @@ public class RocksDBStoreTest extends 
AbstractKeyValueStoreTest {
         }
     }
 
+    @Test
+    public void 
readOnlyCommittedShouldHideStagedPutWhileUncommittedExposesIt() {
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        final Bytes key = new Bytes(stringSerializer.serialize(null, "k"));
+        rocksDBStore.put(key, stringSerializer.serialize(null, "committed"));
+        rocksDBStore.commit(Map.of());
+
+        rocksDBStore.put(key, stringSerializer.serialize(null, "staged"));
+
+        final ReadOnlyKeyValueStore<Bytes, byte[]> uncommitted = 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+        final ReadOnlyKeyValueStore<Bytes, byte[]> committed = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+        assertEquals("staged", stringDeserializer.deserialize(null, 
uncommitted.get(key)));
+        assertEquals("committed", stringDeserializer.deserialize(null, 
committed.get(key)));
+    }
+
+    @Test
+    public void readOnlyCommittedShouldNotSeeStagedDelete() {
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        final Bytes key = new Bytes(stringSerializer.serialize(null, "k"));
+        rocksDBStore.put(key, stringSerializer.serialize(null, "v"));
+        rocksDBStore.commit(Map.of());
+
+        rocksDBStore.delete(key);
+
+        
assertNull(rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED).get(key));
+        assertEquals("v", stringDeserializer.deserialize(null,
+            rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED).get(key)));
+    }
+
+    @Test
+    public void readOnlyRangeAndAllShouldRespectIsolationLevel() {
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        final Bytes k1 = new Bytes(stringSerializer.serialize(null, "k1"));
+        final Bytes k2 = new Bytes(stringSerializer.serialize(null, "k2"));
+        final Bytes k3 = new Bytes(stringSerializer.serialize(null, "k3"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "a"));
+        rocksDBStore.put(k2, stringSerializer.serialize(null, "b"));
+        rocksDBStore.commit(Map.of());
+
+        rocksDBStore.put(k3, stringSerializer.serialize(null, "c"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "a2"));
+
+        final ReadOnlyKeyValueStore<Bytes, byte[]> uncommitted = 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+        final ReadOnlyKeyValueStore<Bytes, byte[]> committed = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+        final List<KeyValue<String, String>> uncommittedAll;
+        try (KeyValueIterator<Bytes, byte[]> it = uncommitted.all()) {
+            uncommittedAll = getDeserializedList(it);
+        }
+        final List<KeyValue<String, String>> committedAll;
+        try (KeyValueIterator<Bytes, byte[]> it = committed.all()) {
+            committedAll = getDeserializedList(it);
+        }
+        assertEquals(List.of(KeyValue.pair("k1", "a2"), KeyValue.pair("k2", 
"b"), KeyValue.pair("k3", "c")), uncommittedAll);
+        assertEquals(List.of(KeyValue.pair("k1", "a"), KeyValue.pair("k2", 
"b")), committedAll);
+
+        final List<KeyValue<String, String>> uncommittedRange;
+        try (KeyValueIterator<Bytes, byte[]> it = uncommitted.range(k1, k3)) {
+            uncommittedRange = getDeserializedList(it);
+        }
+        final List<KeyValue<String, String>> committedRange;
+        try (KeyValueIterator<Bytes, byte[]> it = committed.range(k1, k3)) {
+            committedRange = getDeserializedList(it);
+        }
+        assertEquals(List.of(KeyValue.pair("k1", "a2"), KeyValue.pair("k2", 
"b"), KeyValue.pair("k3", "c")), uncommittedRange);
+        assertEquals(List.of(KeyValue.pair("k1", "a"), KeyValue.pair("k2", 
"b")), committedRange);
+    }
+
+    @Test
+    public void readOnlyReverseRangeAndReverseAllShouldRespectIsolationLevel() 
{
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        final Bytes k1 = new Bytes(stringSerializer.serialize(null, "k1"));
+        final Bytes k2 = new Bytes(stringSerializer.serialize(null, "k2"));
+        final Bytes k3 = new Bytes(stringSerializer.serialize(null, "k3"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "a"));
+        rocksDBStore.put(k2, stringSerializer.serialize(null, "b"));
+        rocksDBStore.commit(Map.of());
+
+        rocksDBStore.put(k3, stringSerializer.serialize(null, "c"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "a2"));
+
+        final ReadOnlyKeyValueStore<Bytes, byte[]> uncommitted = 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+        final ReadOnlyKeyValueStore<Bytes, byte[]> committed = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+        final List<KeyValue<String, String>> uncommittedReverseAll;
+        try (KeyValueIterator<Bytes, byte[]> it = uncommitted.reverseAll()) {
+            uncommittedReverseAll = getDeserializedList(it);
+        }
+        final List<KeyValue<String, String>> committedReverseAll;
+        try (KeyValueIterator<Bytes, byte[]> it = committed.reverseAll()) {
+            committedReverseAll = getDeserializedList(it);
+        }
+        assertEquals(List.of(KeyValue.pair("k3", "c"), KeyValue.pair("k2", 
"b"), KeyValue.pair("k1", "a2")), uncommittedReverseAll);
+        assertEquals(List.of(KeyValue.pair("k2", "b"), KeyValue.pair("k1", 
"a")), committedReverseAll);
+
+        final List<KeyValue<String, String>> uncommittedReverseRange;
+        try (KeyValueIterator<Bytes, byte[]> it = uncommitted.reverseRange(k1, 
k3)) {
+            uncommittedReverseRange = getDeserializedList(it);
+        }
+        final List<KeyValue<String, String>> committedReverseRange;
+        try (KeyValueIterator<Bytes, byte[]> it = committed.reverseRange(k1, 
k3)) {
+            committedReverseRange = getDeserializedList(it);
+        }
+        assertEquals(List.of(KeyValue.pair("k3", "c"), KeyValue.pair("k2", 
"b"), KeyValue.pair("k1", "a2")), uncommittedReverseRange);
+        assertEquals(List.of(KeyValue.pair("k2", "b"), KeyValue.pair("k1", 
"a")), committedReverseRange);
+    }
+
+    @Test
+    public void 
readOnlyReverseRangeShouldReturnEmptyIteratorWhenFromIsGreaterThanTo() {
+        rocksDBStore.init(context, rocksDBStore);
+        final Bytes k1 = new Bytes(stringSerializer.serialize(null, "k1"));
+        final Bytes k2 = new Bytes(stringSerializer.serialize(null, "k2"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "a"));
+        rocksDBStore.put(k2, stringSerializer.serialize(null, "b"));
+
+        try (KeyValueIterator<Bytes, byte[]> it =
+                 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED).reverseRange(k2, k1)) {
+            assertFalse(it.hasNext());
+        }
+    }
+
+    @Test
+    public void readOnlyPrefixScanShouldRespectIsolationLevel() {
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        rocksDBStore.put(new Bytes(stringSerializer.serialize(null, "p-1")), 
stringSerializer.serialize(null, "a"));
+        rocksDBStore.commit(Map.of());
+        rocksDBStore.put(new Bytes(stringSerializer.serialize(null, "p-2")), 
stringSerializer.serialize(null, "b"));
+        rocksDBStore.put(new Bytes(stringSerializer.serialize(null, "q-1")), 
stringSerializer.serialize(null, "z"));
+
+        final List<KeyValue<String, String>> uncommittedPrefix;
+        try (KeyValueIterator<Bytes, byte[]> it = 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED)
+                .prefixScan("p-", stringSerializer)) {
+            uncommittedPrefix = getDeserializedList(it);
+        }
+        final List<KeyValue<String, String>> committedPrefix;
+        try (KeyValueIterator<Bytes, byte[]> it = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED)
+                .prefixScan("p-", stringSerializer)) {
+            committedPrefix = getDeserializedList(it);
+        }
+        assertEquals(List.of(KeyValue.pair("p-1", "a"), KeyValue.pair("p-2", 
"b")), uncommittedPrefix);
+        assertEquals(List.of(KeyValue.pair("p-1", "a")), committedPrefix);
+    }
+
+    @Test
+    public void 
readOnlyOnNonTransactionalStoreShouldBehaveIdenticallyAcrossLevels() {
+        rocksDBStore.init(context, rocksDBStore);
+        final Bytes key = new Bytes(stringSerializer.serialize(null, "k"));
+        rocksDBStore.put(key, stringSerializer.serialize(null, "v"));
+
+        assertEquals("v", stringDeserializer.deserialize(null,
+            rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED).get(key)));
+        assertEquals("v", stringDeserializer.deserialize(null,
+            rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED).get(key)));
+    }
+
     public static class TestingBloomFilterRocksDBConfigSetter implements 
RocksDBConfigSetter {
 
         static boolean bloomFiltersSet;

Reply via email to