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 1dc4cef0bca KAFKA-20493: Transactional RocksDBStore (#22625)
1dc4cef0bca is described below

commit 1dc4cef0bcade1cfc347af4684dece9c998fa8d4
Author: Nick Telford <[email protected]>
AuthorDate: Mon Jun 22 23:52:19 2026 +0100

    KAFKA-20493: Transactional RocksDBStore (#22625)
    
    Adds a transactional implementation of `RocksDBStore`, building on the
    transaction-buffer framework (KAFKA-20490) and the dual-column-family
    accessor work (KAFKA-20492).
    
    `RocksDBTransactionBuffer` implements `TransactionBuffer` for
    RocksDB-backed stores: writes are staged in the inherited
    `pendingWrites` map and accumulated in a RocksDB `WriteBatch` that is
    applied atomically on commit. Reads merge the staged writes over a base
    iterator opened against the underlying RocksDB instance, so transactions
    observe their own uncommitted writes. The buffer owns the full
    range-tombstone lifecycle — `stageDeleteRange`, the `rangeTombstones`
    map, and tombstone-aware overrides of `get`, `all`, `range`, `commit`,
    `rollback`, and `isEmpty` — since only RocksDB stores use `deleteRange`.
    
    `TransactionalDBAccessor`, a `DBAccessor` decorator, intercepts reads
    and writes to route them through the buffer. It is installed in
    `openDB()` only when transactional state stores are enabled, so the
    non-transactional path is unchanged. The store reports its pending
    write-buffer size via `approximateNumUncommittedBytes()`, feeding the
    uncommitted-bytes limit added in KAFKA-20491.
    
    For dual-column-family stores, all `put`/`delete`/`deleteRange` and
    `all`/`range`/`prefixScan` calls are routed unconditionally through
    CF-aware buffer overloads. This fixes two latent invariants under EOS:
    old-CF writes were previously flushed outside the shared `WriteBatch`
    (so they could commit independently of new-CF writes), and
    in-transaction iterators on the old CF saw the pre-delete committed
    value because the in-memory tombstone was never placed in
    `pendingWrites`. Because `DualColumnFamilyAccessor` always pairs writes
    symmetrically, a single shared `pendingWrites` map yields the correct
    final state for both CFs and a single `db.write` on commit applies all
    column families atomically.
    
    This is part of
    
    
[KIP-892](https://cwiki.apache.org/confluence/display/KAFKA/KIP-892%3A+Transactional+Semantics+for+State+Stores).
    
    Reviewers: Bill Bejeck <[email protected]>
---
 gradle/spotbugs-exclude.xml                        |   7 +
 .../internals/AbstractColumnFamilyAccessor.java    |   5 +
 .../state/internals/DualColumnFamilyAccessor.java  |   5 +
 .../streams/state/internals/RocksDBStore.java      | 149 +++++++
 .../state/internals/RocksDBTransactionBuffer.java  | 323 ++++++++++++++++
 .../DualColumnFamilyAccessorTransactionalTest.java | 318 +++++++++++++++
 .../internals/RocksDBTimestampedStoreTest.java     |  58 +++
 .../internals/RocksDBTransactionBufferTest.java    | 428 +++++++++++++++++++++
 8 files changed, 1293 insertions(+)

diff --git a/gradle/spotbugs-exclude.xml b/gradle/spotbugs-exclude.xml
index 6d18691973a..7268ffc6ebe 100644
--- a/gradle/spotbugs-exclude.xml
+++ b/gradle/spotbugs-exclude.xml
@@ -240,6 +240,13 @@ For a detailed description of spotbugs bug categories, see 
https://spotbugs.read
         <Bug 
pattern="AT_NONATOMIC_64BIT_PRIMITIVE,AT_NONATOMIC_OPERATIONS_ON_SHARED_VARIABLE,AT_STALE_THREAD_WRITE_OF_PRIMITIVE"/>
     </Match>
 
+    <Match>
+        <!-- null means "not staged, fall back to base store"; 
Optional.empty() means staged tombstone -->
+        <Class 
name="org.apache.kafka.streams.state.internals.RocksDBTransactionBuffer"/>
+        <Method name="get"/>
+        <Bug pattern="NP_OPTIONAL_RETURN_NULL"/>
+    </Match>
+
     <Match>
         <!-- Suppress warnings about ignoring the return value of await.
              This is done intentionally because we use other clues to determine
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessor.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessor.java
index eb9958b3a7f..c24b689885e 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessor.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessor.java
@@ -121,6 +121,11 @@ abstract class AbstractColumnFamilyAccessor implements 
RocksDBStore.ColumnFamily
     }
 
 
+    @Override
+    public final ColumnFamilyHandle offsetsColumnFamily() {
+        return offsetColumnFamilyHandle;
+    }
+
     // Visible for testing
     ColumnFamilyHandle offsetColumnFamilyHandle() {
         return offsetColumnFamilyHandle;
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessor.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessor.java
index 10664c1326b..4b2f7ba5bf4 100644
--- 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessor.java
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessor.java
@@ -262,6 +262,11 @@ class DualColumnFamilyAccessor extends 
AbstractColumnFamilyAccessor {
         return oldColumnFamily;
     }
 
+    @Override
+    public ColumnFamilyHandle dataColumnFamily() {
+        return newColumnFamily;
+    }
+
     // Visible for testing
     ColumnFamilyHandle newColumnFamily() {
         return newColumnFamily;
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 49b38c23465..e2fb110dcd1 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
@@ -282,6 +282,12 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
             throw e;
         }
 
+        final boolean transactional = StreamsConfig.InternalConfig.getBoolean(
+            configs, StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, false);
+        if (transactional) {
+            dbAccessor = new TransactionalDBAccessor(dbAccessor, db, 
cfAccessor.dataColumnFamily(), cfAccessor.offsetsColumnFamily(), wOptions, 
name);
+        }
+
         addValueProvidersToMetricsRecorder();
     }
 
@@ -732,6 +738,14 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
      *
      * @return an approximate count of key-value mappings in the store.
      */
+    @Override
+    public long approximateNumUncommittedBytes() {
+        if (dbAccessor instanceof TransactionalDBAccessor) {
+            return ((TransactionalDBAccessor) 
dbAccessor).buffer.approximateNumUncommittedBytes();
+        }
+        return 0;
+    }
+
     @Override
     public long approximateNumEntries() {
         validateStoreOpen();
@@ -1034,6 +1048,116 @@ public class RocksDBStore implements 
KeyValueStore<Bytes, byte[]>, BatchWritingS
     }
 
 
+    static class TransactionalDBAccessor implements DBAccessor {
+
+        private final DBAccessor underlying;
+        private final RocksDBTransactionBuffer buffer;
+        private final ColumnFamilyHandle offsetsColumnFamily;
+
+        TransactionalDBAccessor(final DBAccessor underlying,
+                                final RocksDB db,
+                                final ColumnFamilyHandle dataColumnFamily,
+                                final ColumnFamilyHandle offsetsColumnFamily,
+                                final WriteOptions wOptions,
+                                final String storeName) {
+            this.underlying = underlying;
+            this.offsetsColumnFamily = offsetsColumnFamily;
+            this.buffer = new RocksDBTransactionBuffer(db, dataColumnFamily, 
wOptions, storeName);
+        }
+
+        @Override
+        public byte[] get(final ColumnFamilyHandle columnFamily, final byte[] 
key) throws RocksDBException {
+            if (!columnFamily.equals(offsetsColumnFamily)) {
+                final java.util.Optional<byte[]> staged = 
buffer.get(Bytes.wrap(key));
+                if (staged != null) {
+                    return staged.orElse(null);
+                }
+            }
+            return underlying.get(columnFamily, key);
+        }
+
+        @Override
+        public byte[] get(final ColumnFamilyHandle columnFamily, final 
ReadOptions readOptions, final byte[] key) throws RocksDBException {
+            if (!columnFamily.equals(offsetsColumnFamily)) {
+                final java.util.Optional<byte[]> staged = 
buffer.get(Bytes.wrap(key));
+                if (staged != null) {
+                    return staged.orElse(null);
+                }
+            }
+            return underlying.get(columnFamily, readOptions, key);
+        }
+
+        @Override
+        public RocksIterator newIterator(final ColumnFamilyHandle 
columnFamily) {
+            return underlying.newIterator(columnFamily);
+        }
+
+        @Override
+        public void put(final ColumnFamilyHandle columnFamily, final byte[] 
key, final byte[] value) throws RocksDBException {
+            buffer.stage(columnFamily, Bytes.wrap(key), value);
+        }
+
+        @Override
+        public void delete(final ColumnFamilyHandle columnFamily, final byte[] 
key) throws RocksDBException {
+            buffer.stage(columnFamily, Bytes.wrap(key), null);
+        }
+
+        @Override
+        public void deleteRange(final ColumnFamilyHandle columnFamily, final 
byte[] from, final byte[] to) throws RocksDBException {
+            buffer.stageDeleteRange(columnFamily, Bytes.wrap(from), 
Bytes.wrap(to));
+        }
+
+        @Override
+        public long approximateNumEntries(final ColumnFamilyHandle 
columnFamily) throws RocksDBException {
+            return underlying.approximateNumEntries(columnFamily);
+        }
+
+        @Override
+        public void flush(final ColumnFamilyHandle... columnFamilies) throws 
RocksDBException {
+            underlying.flush(columnFamilies);
+        }
+
+        @Override
+        public void reset() {
+            underlying.reset();
+        }
+
+        @Override
+        public void close() {
+            buffer.close();
+            underlying.close();
+        }
+
+        @Override
+        public ManagedKeyValueIterator<Bytes, byte[]> all(final 
ColumnFamilyHandle cf, final String storeName, final boolean forward) {
+            return buffer.all(cf, forward);
+        }
+
+        @Override
+        public ManagedKeyValueIterator<Bytes, byte[]> range(final 
ColumnFamilyHandle cf, final String storeName,
+                                                              final Bytes 
from, final Bytes to,
+                                                              final boolean 
forward, final boolean toInclusive) {
+            return buffer.range(cf, from, to, forward, toInclusive);
+        }
+
+        @Override
+        public ManagedKeyValueIterator<Bytes, byte[]> prefixScan(final 
ColumnFamilyHandle cf, final String storeName,
+                                                                    final 
Bytes prefix, final Bytes to) {
+            return buffer.range(cf, prefix, to, true, false);
+        }
+
+        @Override
+        public void commitStagedWrites() {
+            buffer.commit();
+        }
+
+        @Override
+        public void rollbackStagedWrites() {
+            buffer.rollback();
+        }
+
+    }
+
     interface ColumnFamilyAccessor {
 
         void put(final DBAccessor accessor, final byte[] key, final byte[] 
value);
@@ -1086,6 +1210,26 @@ public class RocksDBStore implements 
KeyValueStore<Bytes, byte[]>, BatchWritingS
         Position open(final RocksDBStore.DBAccessor accessor, final boolean 
ignoreInvalidState) throws RocksDBException, StreamsException;
 
         Long getCommittedOffset(final RocksDBStore.DBAccessor accessor, final 
TopicPartition partition) throws RocksDBException;
+
+        /**
+         * Returns the primary data column family handle.
+         *
+         * <p>This is the CF that all live puts target. It is passed to the 
transaction buffer
+         * as its default CF for WriteBatch staging.
+         *
+         * <p>For dual-CF (upgrade-mode) stores this is the new-format CF, 
since all puts
+         * land there and reads check it first.
+         */
+        ColumnFamilyHandle dataColumnFamily();
+
+        /**
+         * Returns the column family handle used to persist offset metadata.
+         *
+         * <p>Reads from this CF must bypass the staged-write buffer so they 
always reflect
+         * committed state, guarding against the case where a data key 
coincidentally matches
+         * an offset key in the buffer.
+         */
+        ColumnFamilyHandle offsetsColumnFamily();
     }
 
     class SingleColumnFamilyAccessor extends AbstractColumnFamilyAccessor {
@@ -1195,6 +1339,11 @@ public class RocksDBStore implements 
KeyValueStore<Bytes, byte[]>, BatchWritingS
             }
         }
 
+        @Override
+        public ColumnFamilyHandle dataColumnFamily() {
+            return columnFamily;
+        }
+
         // Visible for testing
         ColumnFamilyHandle columnFamily() {
             return columnFamily;
diff --git 
a/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
new file mode 100644
index 00000000000..ec7d4cd41bf
--- /dev/null
+++ 
b/streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBuffer.java
@@ -0,0 +1,323 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.errors.ProcessorStateException;
+
+import org.rocksdb.ColumnFamilyHandle;
+import org.rocksdb.RocksDB;
+import org.rocksdb.RocksDBException;
+import org.rocksdb.RocksIterator;
+import org.rocksdb.WriteBatch;
+import org.rocksdb.WriteOptions;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.NoSuchElementException;
+import java.util.Optional;
+import java.util.TreeMap;
+
+/**
+ * A {@link TransactionBuffer} implementation for RocksDB-backed stores.
+ * Uses a {@link WriteBatch} (without index) to accumulate writes for atomic 
commit.
+ * Reads are handled entirely by the shared {@code ConcurrentSkipListMap} in
+ * {@link AbstractTransactionBuffer}, so a {@code WriteBatchWithIndex} is not 
needed.
+ * <p>
+ * Range deletions ({@link #stageDeleteRange}) are only supported by 
RocksDB-backed stores
+ * and are owned entirely by this class; {@link AbstractTransactionBuffer} 
carries no
+ * tombstone state.
+ */
+class RocksDBTransactionBuffer extends AbstractTransactionBuffer<Bytes> {
+
+    private final RocksDB db;
+    private final ColumnFamilyHandle cfHandle;
+    private final WriteOptions wOptions;
+    private final String storeName;
+    private WriteBatch writeBatch;
+    private volatile NavigableMap<Bytes, List<Bytes>> rangeTombstones = 
Collections.emptyNavigableMap();
+
+    RocksDBTransactionBuffer(final RocksDB db,
+                             final ColumnFamilyHandle cfHandle,
+                             final WriteOptions wOptions,
+                             final String storeName) {
+        this.db = db;
+        this.cfHandle = cfHandle;
+        this.wOptions = wOptions;
+        this.storeName = storeName;
+        this.writeBatch = new WriteBatch();
+    }
+
+    @Override
+    int estimateKeySize(final Bytes key) {
+        return key.get().length;
+    }
+
+    @Override
+    void stageToBackend(final Bytes key, final byte[] value) {
+        stage(cfHandle, key, value);
+    }
+
+    /**
+     * Stages a write for an explicit column family. Updates the shared read 
buffer
+     * ({@code pendingWrites}) and appends the write to the shared {@link 
WriteBatch}
+     * under {@code cf}, so every staged CF is committed atomically on {@link 
#commit()}.
+     */
+    void stage(final ColumnFamilyHandle cf, final Bytes key, final byte[] 
value) {
+        pendingWrites.put(key, Optional.ofNullable(value));
+        pendingWritesBytes += estimateKeySize(key) + (value != null ? 
value.length : 0);
+        try {
+            if (value != null) {
+                writeBatch.put(cf, key.get(), value);
+            } else {
+                writeBatch.delete(cf, key.get());
+            }
+        } catch (final RocksDBException e) {
+            throw new ProcessorStateException("Error staging write in 
transaction buffer for store " + storeName, e);
+        }
+    }
+
+    /**
+     * Stages a range deletion for an explicit column family. Updates the 
shared
+     * {@code pendingWrites} and {@code rangeTombstones} so iterators opened 
before
+     * commit hide the deleted range, and appends the range delete to the 
shared
+     * {@link WriteBatch} under {@code cf}.
+     */
+    void stageDeleteRange(final ColumnFamilyHandle cf, final Bytes from, final 
Bytes to) {
+        pendingWrites.subMap(from, true, to, false).clear();
+        final TreeMap<Bytes, List<Bytes>> copy = new 
TreeMap<>(rangeTombstones);
+        copy.computeIfAbsent(from, k -> new ArrayList<>()).add(to);
+        rangeTombstones = copy;
+        pendingWritesBytes += estimateKeySize(from) + estimateKeySize(to);
+        try {
+            writeBatch.deleteRange(cf, from.get(), to.get());
+        } catch (final RocksDBException e) {
+            throw new ProcessorStateException("Error staging delete range in 
transaction buffer for store " + storeName, e);
+        }
+    }
+
+    @Override
+    public Optional<byte[]> get(final Bytes key) {
+        final Optional<byte[]> staged = pendingWrites.get(key);
+        if (staged != null) {
+            return staged;
+        }
+        if (isCoveredByRangeTombstone(key, rangeTombstones)) {
+            return Optional.empty();
+        }
+        return null;
+    }
+
+    @Override
+    public boolean isEmpty() {
+        return super.isEmpty() && rangeTombstones.isEmpty();
+    }
+
+    ManagedKeyValueIterator<Bytes, byte[]> all(final ColumnFamilyHandle cf, 
final boolean forward) {
+        if (Thread.currentThread() == ownerThread) {
+            final ManagedKeyValueIterator<Bytes, byte[]> baseIter = 
newBaseIterator(cf, null, null, forward, true);
+            return new StagedMergeIterator<>(pendingWrites, baseIter, forward);
+        }
+        return snapshotScan(cf, null, null, forward, true);
+    }
+
+    ManagedKeyValueIterator<Bytes, byte[]> range(final ColumnFamilyHandle cf,
+                                                 final Bytes from, final Bytes 
to,
+                                                 final boolean forward, final 
boolean toInclusive) {
+        if (Thread.currentThread() == ownerThread) {
+            final NavigableMap<Bytes, Optional<byte[]>> stagingView = 
boundStaging(from, to, toInclusive);
+            final ManagedKeyValueIterator<Bytes, byte[]> baseIter = 
newBaseIterator(cf, from, to, forward, toInclusive);
+            return new StagedMergeIterator<>(stagingView, baseIter, forward);
+        }
+        return snapshotScan(cf, from, to, forward, toInclusive);
+    }
+
+    private ManagedKeyValueIterator<Bytes, byte[]> snapshotScan(final 
ColumnFamilyHandle cf,
+                                                                final Bytes 
from, final Bytes to,
+                                                                final boolean 
forward, final boolean toInclusive) {
+        snapshotLock.readLock().lock();
+        try {
+            final NavigableMap<Bytes, Optional<byte[]>> stagingSnapshot =
+                new TreeMap<>(boundStaging(from, to, toInclusive));
+            final ManagedKeyValueIterator<Bytes, byte[]> baseIter = 
newBaseIterator(cf, from, to, forward, toInclusive);
+            return new StagedMergeIterator<>(stagingSnapshot, baseIter, 
forward);
+        } finally {
+            snapshotLock.readLock().unlock();
+        }
+    }
+
+    @Override
+    ManagedKeyValueIterator<Bytes, byte[]> newBaseIterator(final Bytes from, 
final Bytes to) {
+        return newBaseIterator(cfHandle, from, to, true, true);
+    }
+
+    @Override
+    ManagedKeyValueIterator<Bytes, byte[]> newBaseIterator(final Bytes from, 
final Bytes to,
+                                                           final boolean 
forward, final boolean toInclusive) {
+        return newBaseIterator(cfHandle, from, to, forward, toInclusive);
+    }
+
+    private ManagedKeyValueIterator<Bytes, byte[]> newBaseIterator(final 
ColumnFamilyHandle cf,
+                                                                   final Bytes 
from, final Bytes to,
+                                                                   final 
boolean forward, final boolean toInclusive) {
+        final RocksIterator rocksIterator = db.newIterator(cf);
+        final ManagedKeyValueIterator<Bytes, byte[]> iter;
+        if (from != null && to != null) {
+            iter = new RocksDBRangeIterator(storeName, rocksIterator, from, 
to, forward, toInclusive);
+        } else if (from != null && forward) {
+            rocksIterator.seek(from.get());
+            iter = new RocksDbIterator(storeName, rocksIterator, true);
+        } else if (!forward) {
+            if (to != null) {
+                rocksIterator.seekForPrev(to.get());
+            } else {
+                rocksIterator.seekToLast();
+            }
+            iter = new RocksDbIterator(storeName, rocksIterator, false);
+        } else {
+            rocksIterator.seekToFirst();
+            iter = new RocksDbIterator(storeName, rocksIterator, true);
+        }
+        // RocksDbIterator requires onClose to be set before close() is called.
+        // Since this iterator is used internally by StagedMergeIterator (not
+        // tracked by RocksDBStore's open-iterator set), use a no-op callback.
+        iter.onClose(() -> { });
+        if (rangeTombstones.isEmpty()) {
+            return iter;
+        }
+        return new RangeTombstoneFilterIterator(iter, rangeTombstones);
+    }
+
+    @Override
+    void flushToBase() {
+        try {
+            db.write(wOptions, writeBatch);
+        } catch (final RocksDBException e) {
+            throw new ProcessorStateException("Error committing transaction 
buffer for store " + storeName, e);
+        }
+        writeBatch.close();
+        writeBatch = new WriteBatch();
+        rangeTombstones = Collections.emptyNavigableMap();
+    }
+
+    @Override
+    void discardPendingBatch() {
+        writeBatch.clear();
+        rangeTombstones = Collections.emptyNavigableMap();
+    }
+
+    @Override
+    public long approximateNumUncommittedBytes() {
+        return super.approximateNumUncommittedBytes() + 
writeBatch.getDataSize();
+    }
+
+    @Override
+    public void close() {
+        writeBatch.close();
+    }
+
+    static boolean isCoveredByRangeTombstone(final Bytes key,
+                                              final NavigableMap<Bytes, 
List<Bytes>> tombstones) {
+        if (tombstones.isEmpty()) {
+            return false;
+        }
+        for (final Map.Entry<Bytes, List<Bytes>> entry : 
tombstones.headMap(key, true).entrySet()) {
+            for (final Bytes to : entry.getValue()) {
+                if (key.compareTo(to) < 0) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private static class RangeTombstoneFilterIterator implements 
ManagedKeyValueIterator<Bytes, byte[]> {
+
+        private final ManagedKeyValueIterator<Bytes, byte[]> wrapped;
+        private final NavigableMap<Bytes, List<Bytes>> tombstones;
+        private KeyValue<Bytes, byte[]> prefetched;
+        private boolean closed = false;
+        private Runnable closeCallback;
+
+        RangeTombstoneFilterIterator(final ManagedKeyValueIterator<Bytes, 
byte[]> wrapped,
+                                      final NavigableMap<Bytes, List<Bytes>> 
tombstones) {
+            this.wrapped = wrapped;
+            this.tombstones = tombstones;
+        }
+
+        @Override
+        public void onClose(final Runnable closeCallback) {
+            this.closeCallback = closeCallback;
+        }
+
+        @Override
+        public boolean hasNext() {
+            if (closed) {
+                throw new IllegalStateException("Iterator has already been 
closed.");
+            }
+            if (prefetched != null) {
+                return true;
+            }
+            prefetched = computeNext();
+            return prefetched != null;
+        }
+
+        @Override
+        public KeyValue<Bytes, byte[]> next() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            final KeyValue<Bytes, byte[]> result = prefetched;
+            prefetched = null;
+            return result;
+        }
+
+        @Override
+        public Bytes peekNextKey() {
+            if (!hasNext()) {
+                throw new NoSuchElementException();
+            }
+            return prefetched.key;
+        }
+
+        @Override
+        public void close() {
+            closed = true;
+            try {
+                wrapped.close();
+            } finally {
+                if (closeCallback != null) {
+                    closeCallback.run();
+                }
+            }
+        }
+
+        private KeyValue<Bytes, byte[]> computeNext() {
+            while (wrapped.hasNext()) {
+                final KeyValue<Bytes, byte[]> entry = wrapped.next();
+                if (!isCoveredByRangeTombstone(entry.key, tombstones)) {
+                    return entry;
+                }
+            }
+            return null;
+        }
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessorTransactionalTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessorTransactionalTest.java
new file mode 100644
index 00000000000..d61fc2c6fb7
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/DualColumnFamilyAccessorTransactionalTest.java
@@ -0,0 +1,318 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.processor.StateStoreContext;
+import org.apache.kafka.streams.query.Position;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.rocksdb.ColumnFamilyDescriptor;
+import org.rocksdb.ColumnFamilyHandle;
+import org.rocksdb.ColumnFamilyOptions;
+import org.rocksdb.DBOptions;
+import org.rocksdb.FlushOptions;
+import org.rocksdb.RocksDB;
+import org.rocksdb.RocksDBException;
+import org.rocksdb.WriteOptions;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+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;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Regression tests ensuring that {@link DualColumnFamilyAccessor} scans 
honour writes staged in a
+ * {@link RocksDBStore.TransactionalDBAccessor} transaction buffer before they 
are committed to
+ * RocksDB.
+ *
+ * Prior to the fix on KIP-892/txn-rocksdb-accessor, DualColumnFamilyAccessor 
built its inner
+ * iterators via {@code accessor.newIterator()}, which TransactionalDBAccessor 
passes straight
+ * through to RocksDB without merging the buffer. This caused 
range/all/prefixScan to miss staged
+ * writes and deletions, breaking read-your-writes under EOS.
+ */
+public class DualColumnFamilyAccessorTransactionalTest {
+
+    static {
+        RocksDB.loadLibrary();
+    }
+
+    private static final String STORE_NAME = "test-store";
+
+    private File dbDir;
+    private RocksDB db;
+    private ColumnFamilyHandle offsetsCFHandle;
+    private ColumnFamilyHandle oldCFHandle;
+    private ColumnFamilyHandle newCFHandle;
+    private WriteOptions wOptions;
+    private FlushOptions flushOptions;
+
+    private RocksDBStore.DBAccessor txnAccessor;
+    private DualColumnFamilyAccessor accessor;
+
+    private static Bytes key(final String k) {
+        return Bytes.wrap(k.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static byte[] val(final String v) {
+        return v.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static String str(final byte[] bytes) {
+        return bytes == null ? null : new String(bytes, 
StandardCharsets.UTF_8);
+    }
+
+    @BeforeEach
+    public void setUp() throws RocksDBException {
+        dbDir = TestUtils.tempDirectory();
+
+        final DBOptions dbOptions = new DBOptions();
+        dbOptions.setCreateIfMissing(true);
+        dbOptions.setCreateMissingColumnFamilies(true);
+
+        final ColumnFamilyOptions cfOptions = new ColumnFamilyOptions();
+        final List<ColumnFamilyDescriptor> cfDescriptors = Arrays.asList(
+            new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY, 
cfOptions),
+            new 
ColumnFamilyDescriptor("old-cf".getBytes(StandardCharsets.UTF_8), cfOptions),
+            new 
ColumnFamilyDescriptor("new-cf".getBytes(StandardCharsets.UTF_8), cfOptions)
+        );
+        final List<ColumnFamilyHandle> cfHandles = new ArrayList<>();
+        db = RocksDB.open(dbOptions, dbDir.getAbsolutePath(), cfDescriptors, 
cfHandles);
+        offsetsCFHandle = cfHandles.get(0);
+        oldCFHandle = cfHandles.get(1);
+        newCFHandle = cfHandles.get(2);
+
+        wOptions = new WriteOptions();
+        wOptions.setDisableWAL(true);
+        flushOptions = new FlushOptions();
+
+        final RocksDBStore.DBAccessor directAccessor =
+                new RocksDBStore.DirectDBAccessor(db, flushOptions, wOptions);
+        txnAccessor = new RocksDBStore.TransactionalDBAccessor(
+                directAccessor, db, newCFHandle, offsetsCFHandle, wOptions, 
STORE_NAME);
+
+        final RocksDBStore store = mock(RocksDBStore.class);
+        store.position = Position.emptyPosition();
+        store.context = mock(StateStoreContext.class);
+        lenient().when(store.name()).thenReturn(STORE_NAME);
+
+        accessor = new DualColumnFamilyAccessor(
+                offsetsCFHandle, oldCFHandle, newCFHandle,
+                v -> ("converted:" + new String(v, StandardCharsets.UTF_8))
+                        .getBytes(StandardCharsets.UTF_8),
+                store,
+                new AtomicBoolean(true));
+    }
+
+    @AfterEach
+    public void tearDown() throws RocksDBException {
+        if (txnAccessor != null) txnAccessor.close();
+        if (offsetsCFHandle != null) offsetsCFHandle.close();
+        if (oldCFHandle != null) oldCFHandle.close();
+        if (newCFHandle != null) newCFHandle.close();
+        if (db != null) db.close();
+        if (wOptions != null) wOptions.close();
+        if (flushOptions != null) flushOptions.close();
+    }
+
+    private List<KeyValue<Bytes, byte[]>> drain(final 
ManagedKeyValueIterator<Bytes, byte[]> it) {
+        it.onClose(() -> { });
+        final List<KeyValue<Bytes, byte[]>> results = new ArrayList<>();
+        while (it.hasNext()) {
+            results.add(it.next());
+        }
+        it.close();
+        return results;
+    }
+
+    @Test
+    public void shouldSeeStagedPutInAll() throws RocksDBException {
+        // Stage a write on the new CF via the dual-CF accessor (no commit).
+        accessor.put(txnAccessor, key("b").get(), val("new-b"));
+
+        final List<KeyValue<Bytes, byte[]>> results = 
drain(accessor.all(txnAccessor, true));
+
+        assertEquals(1, results.size());
+        assertArrayEquals(key("b").get(), results.get(0).key.get());
+        assertArrayEquals(val("new-b"), results.get(0).value);
+    }
+
+    @Test
+    public void shouldSeeStagedPutInRange() throws RocksDBException {
+        accessor.put(txnAccessor, key("b").get(), val("new-b"));
+        accessor.put(txnAccessor, key("d").get(), val("new-d"));
+
+        final List<KeyValue<Bytes, byte[]>> results =
+                drain(accessor.range(txnAccessor, key("a"), key("c"), true));
+
+        assertEquals(1, results.size());
+        assertArrayEquals(key("b").get(), results.get(0).key.get());
+    }
+
+    @Test
+    public void shouldSeeStagedPutInPrefixScan() throws RocksDBException {
+        accessor.put(txnAccessor, key("foo:1").get(), val("new-foo1"));
+        accessor.put(txnAccessor, key("bar:1").get(), val("new-bar"));
+
+        final List<KeyValue<Bytes, byte[]>> results =
+                drain(accessor.prefixScan(txnAccessor, 
Bytes.wrap("foo:".getBytes(StandardCharsets.UTF_8))));
+
+        assertEquals(1, results.size());
+        assertArrayEquals(key("foo:1").get(), results.get(0).key.get());
+    }
+
+    @Test
+    public void shouldSeeStagedDeleteInRange() throws RocksDBException {
+        // Commit a base value to new CF via a direct write, then stage a 
delete.
+        db.put(newCFHandle, wOptions, key("b").get(), val("committed-b"));
+
+        accessor.put(txnAccessor, key("b").get(), null);
+
+        final List<KeyValue<Bytes, byte[]>> results = 
drain(accessor.all(txnAccessor, true));
+
+        assertTrue(results.isEmpty(), "staged delete should suppress the 
committed value");
+    }
+
+    @Test
+    public void shouldSeeStagedDeleteRangeInRange() throws RocksDBException {
+        // Commit base values to new CF, then stage a deleteRange.
+        db.put(newCFHandle, wOptions, key("a").get(), val("a"));
+        db.put(newCFHandle, wOptions, key("b").get(), val("b"));
+        db.put(newCFHandle, wOptions, key("c").get(), val("c"));
+
+        accessor.deleteRange(txnAccessor, key("a").get(), key("c").get());
+
+        final List<KeyValue<Bytes, byte[]>> results = 
drain(accessor.all(txnAccessor, true));
+
+        assertEquals(1, results.size(), "keys a and b should be hidden by the 
staged range tombstone");
+        assertArrayEquals(key("c").get(), results.get(0).key.get());
+    }
+
+    @Test
+    public void shouldPreferStagedNewValueOverCommittedOldValue() throws 
RocksDBException {
+        // Populate old CF only with a committed entry.
+        db.put(oldCFHandle, wOptions, key("x").get(), val("old-x"));
+
+        // Migrate: put() stages delete on old CF and put on new CF.
+        accessor.put(txnAccessor, key("x").get(), val("new-x"));
+
+        final List<KeyValue<Bytes, byte[]>> results = 
drain(accessor.all(txnAccessor, true));
+
+        assertEquals(1, results.size(), "same key should appear exactly once");
+        assertArrayEquals(key("x").get(), results.get(0).key.get());
+        assertArrayEquals(val("new-x"), results.get(0).value,
+                "staged new-format value should win over committed old-format 
value");
+    }
+
+    @Test
+    public void shouldHideStagedDeleteOfUnmigratedKey() throws 
RocksDBException {
+        // Key lives only in the old CF (unmigrated). Stage put(key, null), 
which issues
+        // delete(oldCF, key) + delete(newCF, key). Both go into the shared 
read buffer, so
+        // scans on either CF see the staged tombstone and suppress the 
pre-delete base value.
+        db.put(oldCFHandle, wOptions, key("x").get(), val("old-x"));
+
+        accessor.put(txnAccessor, key("x").get(), null);
+
+        final List<KeyValue<Bytes, byte[]>> results = 
drain(accessor.all(txnAccessor, true));
+
+        assertTrue(results.isEmpty(),
+                "staged old-CF tombstone should hide the unmigrated key from 
scans");
+    }
+
+    @Test
+    public void shouldSeeCommittedValuesAfterCommit() throws RocksDBException {
+        accessor.put(txnAccessor, key("a").get(), val("a"));
+        accessor.put(txnAccessor, key("b").get(), val("b"));
+        txnAccessor.commitStagedWrites();
+
+        // After commit the values are in RocksDB; a fresh scan should return 
both.
+        final List<KeyValue<Bytes, byte[]>> results = 
drain(accessor.all(txnAccessor, true));
+
+        assertEquals(2, results.size());
+        assertArrayEquals(key("a").get(), results.get(0).key.get());
+        assertArrayEquals(key("b").get(), results.get(1).key.get());
+        assertFalse(results.isEmpty());
+    }
+
+    // --- Point get() read-your-writes tests ---
+    //
+    // These tests specifically exercise the get() code path through
+    // TransactionalDBAccessor.get(), which checks the staged-write buffer
+    // for any CF except the offsets CF. The buffer is keyed by key only
+    // (no CF), so it must be consulted for both oldCF and newCF reads.
+
+    @Test
+    public void shouldSeeStagedPutViaGet() throws RocksDBException {
+        accessor.put(txnAccessor, key("a").get(), val("new-a"));
+
+        assertArrayEquals(val("new-a"), accessor.get(txnAccessor, 
key("a").get()),
+                "staged put should be visible through get() before commit");
+    }
+
+    @Test
+    public void shouldSeeStagedDeleteViaGet() throws RocksDBException {
+        // Commit a base value to the new CF, then stage a delete.
+        db.put(newCFHandle, wOptions, key("a").get(), val("committed-a"));
+
+        accessor.put(txnAccessor, key("a").get(), null);
+
+        assertNull(accessor.get(txnAccessor, key("a").get()),
+                "staged delete should be visible through get() before commit");
+    }
+
+    @Test
+    public void shouldPreferStagedNewValueOverCommittedOldValueViaGet() throws 
RocksDBException {
+        // Key exists only in old CF (unmigrated), then we stage a put to 
migrate it.
+        db.put(oldCFHandle, wOptions, key("x").get(), val("old-x"));
+
+        accessor.put(txnAccessor, key("x").get(), val("new-x"));
+
+        assertArrayEquals(val("new-x"), accessor.get(txnAccessor, 
key("x").get()),
+                "staged new-format value should win over committed old-format 
value via get()");
+    }
+
+    @Test
+    public void shouldHideStagedDeleteOfUnmigratedKeyViaGet() throws 
RocksDBException {
+        // Key lives only in old CF. Stage put(key, null) which tombstones 
both CFs.
+        db.put(oldCFHandle, wOptions, key("x").get(), val("old-x"));
+
+        accessor.put(txnAccessor, key("x").get(), null);
+
+        assertNull(accessor.get(txnAccessor, key("x").get()),
+                "staged old-CF tombstone should hide the unmigrated key via 
get()");
+    }
+
+    @Test
+    public void shouldReturnNullForAbsentKeyViaGet() throws RocksDBException {
+        assertNull(accessor.get(txnAccessor, key("absent").get()),
+                "get() for a key with no staged write and no committed value 
should return null");
+    }
+}
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreTest.java
index 3c8f87bda49..e73cecc96cb 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTimestampedStoreTest.java
@@ -16,13 +16,18 @@
  */
 package org.apache.kafka.streams.state.internals;
 
+import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.serialization.Serializer;
 import org.apache.kafka.common.serialization.StringSerializer;
 import org.apache.kafka.common.utils.Bytes;
 import org.apache.kafka.common.utils.LogCaptureAppender;
 import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.StreamsConfig;
 import org.apache.kafka.streams.errors.ProcessorStateException;
 import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.test.InternalMockProcessorContext;
+import org.apache.kafka.test.StreamsTestUtils;
+import org.apache.kafka.test.TestUtils;
 
 import org.hamcrest.core.IsNull;
 import org.junit.jupiter.api.Test;
@@ -36,6 +41,8 @@ import java.io.File;
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
+import java.util.Properties;
 
 import static java.util.Arrays.asList;
 import static 
org.apache.kafka.streams.state.internals.RocksDBStore.OFFSETS_COLUMN_FAMILY_NAME;
@@ -45,6 +52,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 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.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -501,6 +509,56 @@ public class RocksDBTimestampedStoreTest extends 
RocksDBStoreTest {
         }
     }
 
+    /**
+     * Regression test for the null {@code dataColumnFamily} bug under EOS.
+     *
+     * <p>When {@code TRANSACTIONAL_STATE_STORES_CONFIG} is enabled, {@link 
RocksDBStore#openDB}
+     * wraps the underlying accessor in a {@link 
RocksDBStore.TransactionalDBAccessor}. The
+     * accessor's {@code get()} consults the staged-write buffer only when the 
request targets
+     * the distinguished data CF ({@code columnFamily.equals(cfHandle)}). 
Before the fix,
+     * {@link RocksDBTimestampedStore} never set {@code dataColumnFamily}, so 
{@code cfHandle}
+     * was {@code null} and the check was always false — staged writes were 
invisible to
+     * {@code get()}, breaking read-your-writes.
+     */
+    @Test
+    public void 
shouldReadYourWritesViaGetWhenTransactionalTimestampedStoreOpenedUnderEOS() {
+        final Properties props = StreamsTestUtils.getStreamsConfig();
+        props.setProperty(StreamsConfig.PROCESSING_GUARANTEE_CONFIG, 
StreamsConfig.EXACTLY_ONCE_V2);
+        props.setProperty(StreamsConfig.TRANSACTIONAL_STATE_STORES_CONFIG, 
"true");
+        final File stateDir = TestUtils.tempDirectory();
+        final InternalMockProcessorContext<?, ?> txnContext =
+                new InternalMockProcessorContext<>(stateDir, new 
StreamsConfig(props));
+
+        final RocksDBStore txnStore = new RocksDBTimestampedStore(DB_NAME, 
METRICS_SCOPE);
+        try {
+            txnStore.init(txnContext, txnStore);
+
+            final Bytes key = new Bytes("k1".getBytes());
+            final byte[] value = "v1".getBytes();
+
+            // Put a value — this is staged in the transaction buffer but not 
yet committed to RocksDB.
+            txnStore.put(key, value);
+
+            // get() must see the staged write (read-your-writes).
+            assertArrayEquals(value, txnStore.get(key),
+                    "transactional TimestampedStore should return staged value 
via get() before commit");
+
+            // Commit and verify the value persists.
+            final TopicPartition tp = new TopicPartition("changelog", 0);
+            txnStore.commit(Map.of(tp, 1L));
+
+            assertArrayEquals(value, txnStore.get(key),
+                    "transactional TimestampedStore should return value via 
get() after commit");
+
+            // Delete should also be read-your-writes.
+            txnStore.put(key, null);
+            assertNull(txnStore.get(key),
+                    "transactional TimestampedStore should return null for a 
staged delete via get() before commit");
+        } finally {
+            txnStore.close();
+        }
+    }
+
     private void prepareOldStore() {
         final RocksDBStore keyValueStore = new RocksDBStore(DB_NAME, 
METRICS_SCOPE);
         try {
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
new file mode 100644
index 00000000000..0fab3b44e91
--- /dev/null
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBTransactionBufferTest.java
@@ -0,0 +1,428 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.streams.state.internals;
+
+import org.apache.kafka.common.utils.Bytes;
+import org.apache.kafka.streams.KeyValue;
+import org.apache.kafka.streams.state.KeyValueIterator;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.rocksdb.ColumnFamilyDescriptor;
+import org.rocksdb.ColumnFamilyHandle;
+import org.rocksdb.ColumnFamilyOptions;
+import org.rocksdb.DBOptions;
+import org.rocksdb.RocksDB;
+import org.rocksdb.RocksDBException;
+import org.rocksdb.WriteOptions;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicReference;
+
+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;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class RocksDBTransactionBufferTest {
+
+    static {
+        RocksDB.loadLibrary();
+    }
+
+    private File dbDir;
+    private RocksDB db;
+    private ColumnFamilyHandle cfHandle;
+    private ColumnFamilyHandle otherCfHandle;
+    private WriteOptions wOptions;
+    private RocksDBTransactionBuffer buffer;
+
+    private static Bytes key(final String k) {
+        return Bytes.wrap(k.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private static byte[] val(final String v) {
+        return v.getBytes(StandardCharsets.UTF_8);
+    }
+
+    private static String str(final byte[] bytes) {
+        return bytes == null ? null : new String(bytes, 
StandardCharsets.UTF_8);
+    }
+
+    @BeforeEach
+    public void setUp() throws RocksDBException {
+        dbDir = TestUtils.tempDirectory();
+
+        final DBOptions dbOptions = new DBOptions();
+        dbOptions.setCreateIfMissing(true);
+        dbOptions.setCreateMissingColumnFamilies(true);
+
+        final ColumnFamilyOptions cfOptions = new ColumnFamilyOptions();
+        final List<ColumnFamilyDescriptor> cfDescriptors = Arrays.asList(
+            new ColumnFamilyDescriptor(RocksDB.DEFAULT_COLUMN_FAMILY, 
cfOptions),
+            new 
ColumnFamilyDescriptor("other-cf".getBytes(StandardCharsets.UTF_8), cfOptions)
+        );
+        final List<ColumnFamilyHandle> cfHandles = new ArrayList<>();
+        db = RocksDB.open(dbOptions, dbDir.getAbsolutePath(), cfDescriptors, 
cfHandles);
+        cfHandle = cfHandles.get(0);
+        otherCfHandle = cfHandles.get(1);
+
+        wOptions = new WriteOptions();
+        wOptions.setDisableWAL(true);
+
+        // Pre-populate base data
+        db.put(cfHandle, wOptions, key("a").get(), val("base-a"));
+        db.put(cfHandle, wOptions, key("b").get(), val("base-b"));
+        db.put(cfHandle, wOptions, key("c").get(), val("base-c"));
+
+        buffer = new RocksDBTransactionBuffer(db, cfHandle, wOptions, 
"test-store");
+    }
+
+    @AfterEach
+    public void tearDown() {
+        if (buffer != null) buffer.close();
+        if (cfHandle != null) cfHandle.close();
+        if (otherCfHandle != null) otherCfHandle.close();
+        if (db != null) db.close();
+        if (wOptions != null) wOptions.close();
+    }
+
+    @Test
+    public void shouldReturnNullForUnstagedKey() {
+        assertNull(buffer.get(key("a")));
+        assertNull(buffer.get(key("z")));
+    }
+
+    @Test
+    public void shouldReturnStagedValue() {
+        buffer.stage(key("a"), val("staged-a"));
+        final Optional<byte[]> staged = buffer.get(key("a"));
+        assertTrue(staged.isPresent());
+        assertArrayEquals(val("staged-a"), staged.get());
+    }
+
+    @Test
+    public void shouldReturnEmptyOptionalForStagedTombstone() {
+        buffer.stage(key("a"), null);
+        final Optional<byte[]> staged = buffer.get(key("a"));
+        assertEquals(Optional.empty(), staged);
+    }
+
+    @Test
+    public void shouldReturnStagedValueForNewKey() {
+        buffer.stage(key("z"), val("staged-z"));
+        final Optional<byte[]> staged = buffer.get(key("z"));
+        assertTrue(staged.isPresent());
+        assertArrayEquals(val("staged-z"), staged.get());
+    }
+
+    @Test
+    public void shouldReportIsEmpty() {
+        assertTrue(buffer.isEmpty());
+        buffer.stage(key("x"), val("v"));
+        assertFalse(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldCommitStagedWritesToRocksDB() throws RocksDBException {
+        buffer.stage(key("a"), val("new-a"));
+        buffer.stage(key("d"), val("new-d"));
+        buffer.stage(key("b"), null); // delete b
+
+        buffer.commit();
+
+        assertEquals("new-a", str(db.get(cfHandle, key("a").get())));
+        assertNull(db.get(cfHandle, key("b").get()));
+        assertEquals("base-c", str(db.get(cfHandle, key("c").get())));
+        assertEquals("new-d", str(db.get(cfHandle, key("d").get())));
+        assertTrue(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldRollbackWithoutAffectingBase() throws RocksDBException {
+        buffer.stage(key("a"), val("new-a"));
+        buffer.stage(key("d"), val("new-d"));
+
+        buffer.rollback();
+
+        assertEquals("base-a", str(db.get(cfHandle, key("a").get())));
+        assertNull(db.get(cfHandle, key("d").get()));
+        assertTrue(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldMergeStagedWritesInAllScan() {
+        buffer.stage(key("a"), val("staged-a"));
+        buffer.stage(key("d"), val("staged-d"));
+        buffer.stage(key("b"), null); // tombstone
+
+        try (KeyValueIterator<Bytes, byte[]> iter = buffer.all(true)) {
+            final List<String> keys = new ArrayList<>();
+            while (iter.hasNext()) {
+                keys.add(iter.next().key.toString());
+            }
+            assertEquals(List.of("a", "c", "d"), keys);
+        }
+    }
+
+    @Test
+    public void shouldMergeStagedWritesInOwnerAllScan() {
+        buffer.stage(key("a"), val("staged-a"));
+        buffer.stage(key("d"), val("staged-d"));
+
+        try (KeyValueIterator<Bytes, byte[]> iter = buffer.all(true)) {
+            final List<String> keys = new ArrayList<>();
+            while (iter.hasNext()) {
+                keys.add(iter.next().key.toString());
+            }
+            assertEquals(List.of("a", "b", "c", "d"), keys);
+        }
+    }
+
+    @Test
+    public void shouldMergeStagedWritesInRangeScan() {
+        buffer.stage(key("b"), val("staged-b"));
+
+        try (KeyValueIterator<Bytes, byte[]> iter = buffer.range(key("a"), 
key("c"), true, true)) {
+            final List<String> keys = new ArrayList<>();
+            final List<String> values = new ArrayList<>();
+            while (iter.hasNext()) {
+                final KeyValue<Bytes, byte[]> entry = iter.next();
+                keys.add(entry.key.toString());
+                values.add(str(entry.value));
+            }
+            assertEquals(List.of("a", "b", "c"), keys);
+            assertEquals(List.of("base-a", "staged-b", "base-c"), values);
+        }
+    }
+
+    @Test
+    public void shouldSeeStagedWritesFromAnotherThread() throws Exception {
+        buffer.stage(key("x"), val("staged-x"));
+
+        final AtomicReference<Optional<byte[]>> result = new 
AtomicReference<>();
+        final CountDownLatch latch = new CountDownLatch(1);
+
+        final Thread reader = new Thread(() -> {
+            result.set(buffer.get(key("x")));
+            latch.countDown();
+        });
+        reader.start();
+        latch.await();
+
+        assertTrue(result.get().isPresent());
+        assertArrayEquals(val("staged-x"), result.get().get());
+    }
+
+    @Test
+    public void shouldScanFromAnotherThread() throws Exception {
+        buffer.stage(key("d"), val("staged-d"));
+
+        final AtomicReference<List<String>> result = new AtomicReference<>();
+        final CountDownLatch latch = new CountDownLatch(1);
+
+        final Thread reader = new Thread(() -> {
+            try (KeyValueIterator<Bytes, byte[]> iter = buffer.all(true)) {
+                final List<String> keys = new ArrayList<>();
+                while (iter.hasNext()) {
+                    keys.add(iter.next().key.toString());
+                }
+                result.set(keys);
+            }
+            latch.countDown();
+        });
+        reader.start();
+        latch.await();
+
+        assertEquals(List.of("a", "b", "c", "d"), result.get());
+    }
+
+    @Test
+    public void shouldNotShowStagedWritesInBaseAfterRollback() throws 
RocksDBException {
+        buffer.stage(key("x"), val("staged-x"));
+        buffer.rollback();
+
+        assertNull(db.get(cfHandle, key("x").get()));
+        assertNull(buffer.get(key("x")));
+    }
+
+    @Test
+    public void shouldHideDeletedRangeFromPointReads() {
+        buffer.stageDeleteRange(cfHandle, key("a"), key("c"));
+
+        assertEquals(Optional.empty(), buffer.get(key("a")));
+        assertEquals(Optional.empty(), buffer.get(key("b")));
+        assertNull(buffer.get(key("c"))); // exclusive upper bound
+    }
+
+    @Test
+    public void shouldHideDeletedRangeFromScans() {
+        buffer.stageDeleteRange(cfHandle, key("a"), key("c"));
+
+        try (KeyValueIterator<Bytes, byte[]> iter = buffer.all(true)) {
+            final List<String> keys = new ArrayList<>();
+            while (iter.hasNext()) {
+                keys.add(iter.next().key.toString());
+            }
+            assertEquals(List.of("c"), keys);
+        }
+    }
+
+    @Test
+    public void shouldAllowStageAfterDeleteRangeToOverride() {
+        buffer.stageDeleteRange(cfHandle, key("a"), key("d"));
+        buffer.stage(key("b"), val("new-b"));
+
+        final Optional<byte[]> result = buffer.get(key("b"));
+        assertTrue(result.isPresent());
+        assertArrayEquals(val("new-b"), result.get());
+        assertEquals(Optional.empty(), buffer.get(key("a")));
+    }
+
+    @Test
+    public void shouldCommitRangeDeleteToRocksDB() throws RocksDBException {
+        buffer.stageDeleteRange(cfHandle, key("a"), key("c"));
+        buffer.commit();
+
+        assertNull(db.get(cfHandle, key("a").get()));
+        assertNull(db.get(cfHandle, key("b").get()));
+        assertEquals("base-c", str(db.get(cfHandle, key("c").get())));
+        assertTrue(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldCommitRangeDeleteWithOverridingPut() throws 
RocksDBException {
+        buffer.stageDeleteRange(cfHandle, key("a"), key("d"));
+        buffer.stage(key("b"), val("new-b"));
+        buffer.commit();
+
+        assertNull(db.get(cfHandle, key("a").get()));
+        assertEquals("new-b", str(db.get(cfHandle, key("b").get())));
+        assertNull(db.get(cfHandle, key("c").get()));
+        assertTrue(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldRollbackRangeDelete() throws RocksDBException {
+        buffer.stageDeleteRange(cfHandle, key("a"), key("c"));
+        buffer.rollback();
+
+        assertEquals("base-a", str(db.get(cfHandle, key("a").get())));
+        assertEquals("base-b", str(db.get(cfHandle, key("b").get())));
+        assertEquals("base-c", str(db.get(cfHandle, key("c").get())));
+        assertTrue(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldReportNotEmptyWithRangeTombstones() {
+        assertTrue(buffer.isEmpty());
+        buffer.stageDeleteRange(cfHandle, key("a"), key("c"));
+        assertFalse(buffer.isEmpty());
+    }
+
+    @Test
+    public void shouldSupportMultipleCommitRollbackCycles() throws 
RocksDBException {
+        buffer.stage(key("x"), val("v1"));
+        buffer.commit();
+        assertEquals("v1", str(db.get(cfHandle, key("x").get())));
+
+        buffer.stage(key("x"), val("v2"));
+        buffer.rollback();
+        // After rollback, base still has v1
+        assertEquals("v1", str(db.get(cfHandle, key("x").get())));
+        // get() returns null (not staged), caller should check base
+        assertNull(buffer.get(key("x")));
+
+        buffer.stage(key("x"), val("v3"));
+        buffer.commit();
+        assertEquals("v3", str(db.get(cfHandle, key("x").get())));
+    }
+
+    // --- CF-aware overload tests ---
+
+    @Test
+    public void shouldStageWriteToOtherCF() throws RocksDBException {
+        buffer.stage(otherCfHandle, key("x"), val("other-x"));
+
+        // Shared read buffer sees the staged value
+        assertTrue(buffer.get(key("x")).isPresent());
+        assertArrayEquals(val("other-x"), buffer.get(key("x")).get());
+        // Not yet flushed to RocksDB
+        assertNull(db.get(otherCfHandle, key("x").get()));
+
+        buffer.commit();
+        assertEquals("other-x", str(db.get(otherCfHandle, key("x").get())));
+        assertNull(buffer.get(key("x")));
+    }
+
+    @Test
+    public void shouldStageDeleteToOtherCF() throws RocksDBException {
+        db.put(otherCfHandle, wOptions, key("x").get(), val("other-x"));
+
+        buffer.stage(otherCfHandle, key("x"), null);
+
+        // Shared read buffer shows tombstone
+        assertEquals(Optional.empty(), buffer.get(key("x")));
+        // Not yet flushed
+        assertArrayEquals(val("other-x"), db.get(otherCfHandle, 
key("x").get()));
+
+        buffer.commit();
+        assertNull(db.get(otherCfHandle, key("x").get()));
+    }
+
+    @Test
+    public void shouldStageDeleteRangeToOtherCF() throws RocksDBException {
+        db.put(otherCfHandle, wOptions, key("a").get(), val("other-a"));
+        db.put(otherCfHandle, wOptions, key("b").get(), val("other-b"));
+
+        buffer.stageDeleteRange(otherCfHandle, key("a"), key("c"));
+
+        // Shared tombstone hides keys in range from buffer reads
+        assertEquals(Optional.empty(), buffer.get(key("a")));
+        assertEquals(Optional.empty(), buffer.get(key("b")));
+        assertNull(buffer.get(key("c"))); // exclusive upper bound: not covered
+
+        buffer.commit();
+        assertNull(db.get(otherCfHandle, key("a").get()));
+        assertNull(db.get(otherCfHandle, key("b").get()));
+    }
+
+    @Test
+    public void shouldCommitWritesAcrossCFsAtomically() throws 
RocksDBException {
+        buffer.stage(cfHandle, key("d"), val("data-d"));
+        buffer.stage(otherCfHandle, key("o"), val("other-o"));
+
+        // Neither visible in RocksDB before commit
+        assertNull(db.get(cfHandle, key("d").get()));
+        assertNull(db.get(otherCfHandle, key("o").get()));
+
+        buffer.commit();
+
+        assertEquals("data-d", str(db.get(cfHandle, key("d").get())));
+        assertEquals("other-o", str(db.get(otherCfHandle, key("o").get())));
+        assertTrue(buffer.isEmpty());
+    }
+}

Reply via email to