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 15321410af3 KAFKA-20760: Commit position atomically with changelog 
offsets (#22741)
15321410af3 is described below

commit 15321410af3f349f13cf3248372fb7a87a5ef543
Author: Nick Telford <[email protected]>
AuthorDate: Fri Jul 3 01:50:18 2026 +0100

    KAFKA-20760: Commit position atomically with changelog offsets (#22741)
    
    Under EOS, a transactional `RocksDBStore` persisted its `Position` in a
    separate `WriteBatch` flush from the one that persisted the changelog
    offsets. A crash between the two flushes could leave the on-disk
    position inconsistent with the stored offsets, so on restart the store
    could report a position that does not match the data it actually holds.
    
    This change merges the two `commit()` overloads into a single method
    that takes both the in-memory `Position` and the changelog offsets. When
    offsets are present, the position (merged with any uncommitted deltas)
    is written into the *same* staged `WriteBatch` as the offset entries
    before `commitStagedWrites()` runs, so the data, offsets, and position
    all become durable atomically. The in-memory position is advanced only
    after the flush succeeds, so a failure leaves both in-memory and on-disk
    state consistent with the previous commit.
    
    Tests that previously passed an empty changelog-offsets map — which now
    takes the wipe-offsets path — are updated to pass a sentinel offset so
    the position is persisted as intended.
    
    Part of a series of fixes for interactive-query position consistency
    with transactional state stores.
    
    Reviewers: Bill Bejeck <[email protected]>
    
    Co-authored-by: Bill Bejeck <[email protected]>
---
 .../state/internals/AbstractColumnFamilyAccessor.java | 19 +++++++++++++------
 .../kafka/streams/state/internals/RocksDBStore.java   | 18 ++++++------------
 .../internals/AbstractColumnFamilyAccessorTest.java   | 10 +++++-----
 3 files changed, 24 insertions(+), 23 deletions(-)

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 aebcecd75a7..bb872f58ac8 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
@@ -58,8 +58,12 @@ abstract class AbstractColumnFamilyAccessor implements 
RocksDBStore.ColumnFamily
     }
 
     @Override
-    public final void commit(final RocksDBStore.DBAccessor accessor, final 
Map<TopicPartition, Long> changelogOffsets) throws RocksDBException {
+    public final void commit(final RocksDBStore.DBAccessor accessor,
+                             final Position position,
+                             final Map<TopicPartition, Long> changelogOffsets) 
throws RocksDBException {
         if (changelogOffsets.isEmpty()) {
+            // No changelog: wipe any stale changelog-offset keys left from a 
prior
+            // logging configuration, then write the current position below.
             wipeOffsets(accessor);
         } else {
             for (final Map.Entry<TopicPartition, Long> entry : 
changelogOffsets.entrySet()) {
@@ -74,12 +78,15 @@ abstract class AbstractColumnFamilyAccessor implements 
RocksDBStore.ColumnFamily
                 }
             }
         }
+        // Merge committed and uncommitted positions into a temporary copy so 
that if
+        // commitStagedWrites() throws, the in-memory position remains 
consistent with the
+        // last successful commit. Position is written after wipeOffsets so 
the key is not
+        // deleted by the wipe. The in-memory position is updated only on 
success.
+        final Position merged = position.copy();
+        accessor.mergeUncommittedPositionInto(merged);
+        accessor.put(offsetColumnFamilyHandle, positionKey, 
PositionSerde.serialize(merged).array());
         accessor.commitStagedWrites();
-    }
-
-    @Override
-    public final void commit(final RocksDBStore.DBAccessor accessor, final 
Position storePosition) throws RocksDBException {
-        accessor.put(offsetColumnFamilyHandle, positionKey, 
PositionSerde.serialize(storePosition).array());
+        position.merge(merged);
     }
 
     @Override
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 4edd65ae8cd..e7b2eef3a4c 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
@@ -186,7 +186,7 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
         stateStoreContext.register(
             root,
             (RecordBatchingStateRestoreCallback) this::restoreBatch,
-                this::writePosition
+            this::writePosition
         );
         consistencyEnabled = StreamsConfig.InternalConfig.getBoolean(
             stateStoreContext.appConfigs(),
@@ -455,12 +455,7 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
     }
 
     public final void writePosition() {
-        validateStoreOpen();
-        try {
-            cfAccessor.commit(dbAccessor, position);
-        } catch (final RocksDBException e) {
-            log.warn("Error while committing position for store {}", name, e);
-        }
+        // Position is now committed atomically inside commit(); this method 
is a no-op.
     }
 
     @Override
@@ -916,8 +911,7 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
         }
         try {
             synchronized (position) {
-                cfAccessor.commit(dbAccessor, changelogOffsets);
-                dbAccessor.mergeUncommittedPositionInto(position);
+                cfAccessor.commit(dbAccessor, position, changelogOffsets);
             }
         } catch (final RocksDBException e) {
             throw new ProcessorStateException("Error while executing commit 
from store " + name, e);
@@ -1366,9 +1360,9 @@ public class RocksDBStore implements KeyValueStore<Bytes, 
byte[]>, BatchWritingS
 
         long approximateNumEntries(final DBAccessor accessor) throws 
RocksDBException;
 
-        void commit(final DBAccessor accessor, final Map<TopicPartition, Long> 
changelogOffsets) throws RocksDBException;
-
-        void commit(final DBAccessor accessor, final Position storePosition) 
throws RocksDBException;
+        void commit(final DBAccessor accessor,
+                    final Position position,
+                    final Map<TopicPartition, Long> changelogOffsets) throws 
RocksDBException;
 
         void addToBatch(final byte[] key,
                         final byte[] value,
diff --git 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessorTest.java
 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessorTest.java
index d7fbf8dfa75..9473f999041 100644
--- 
a/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessorTest.java
+++ 
b/streams/src/test/java/org/apache/kafka/streams/state/internals/AbstractColumnFamilyAccessorTest.java
@@ -119,7 +119,7 @@ abstract class AbstractColumnFamilyAccessorTest {
         final TopicPartition tp0 = new TopicPartition("testTopic", 0);
         final TopicPartition tp1 = new TopicPartition("testTopic", 1);
         final Map<TopicPartition, Long> changelogOffsets = Map.of(tp0, 10L, 
tp1, 20L);
-        accessor.commit(dbAccessor, changelogOffsets);
+        accessor.commit(dbAccessor, Position.emptyPosition(), 
changelogOffsets);
         assertEquals(10L, accessor.getCommittedOffset(dbAccessor, tp0));
         assertEquals(20L, accessor.getCommittedOffset(dbAccessor, tp1));
     }
@@ -131,14 +131,14 @@ abstract class AbstractColumnFamilyAccessorTest {
         final TopicPartition tp0 = new TopicPartition(topic, 0);
         final TopicPartition tp1 = new TopicPartition(topic, 1);
         final Position positionToStore = Position.fromMap(mkMap(mkEntry(topic, 
mkMap(mkEntry(tp0.partition(), 10L), mkEntry(tp1.partition(), 20L)))));
-        accessor.commit(dbAccessor, positionToStore);
+        accessor.commit(dbAccessor, positionToStore, Map.of(tp0, 0L));
         assertEquals(positionToStore, 
PositionSerde.deserialize(ByteBuffer.wrap(dbAccessor.get(offsetsCF, 
toBytes("position")))));
     }
 
     @Test
     public void shouldCommitStagedWritesWhenCommittingOffsets() throws 
RocksDBException {
         final TopicPartition tp0 = new TopicPartition("testTopic", 0);
-        accessor.commit(dbAccessor, Map.of(tp0, 10L));
+        accessor.commit(dbAccessor, Position.emptyPosition(), Map.of(tp0, 
10L));
         verify(dbAccessor).commitStagedWrites();
     }
 
@@ -147,10 +147,10 @@ abstract class AbstractColumnFamilyAccessorTest {
         dbAccessor = new InMemoryRocksDBAccessor(mock(RocksDB.class));
         final TopicPartition tp0 = new TopicPartition("testTopic", 0);
         final TopicPartition tp1 = new TopicPartition("testTopic", 1);
-        accessor.commit(dbAccessor, Map.of(tp0, 10L, tp1, 20L));
+        accessor.commit(dbAccessor, Position.emptyPosition(), Map.of(tp0, 10L, 
tp1, 20L));
         assertEquals(10L, accessor.getCommittedOffset(dbAccessor, tp0));
         assertEquals(20L, accessor.getCommittedOffset(dbAccessor, tp1));
-        accessor.commit(dbAccessor, Map.of());
+        accessor.commit(dbAccessor, Position.emptyPosition(), Map.of());
         assertNull(accessor.getCommittedOffset(dbAccessor, tp0));
         assertNull(accessor.getCommittedOffset(dbAccessor, tp1));
     }

Reply via email to