mjsax commented on code in PR #23144:
URL: https://github.com/apache/kafka/pull/23144#discussion_r3771162030


##########
streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java:
##########
@@ -1074,6 +1081,18 @@ interface DBAccessor {
         void reset();
         void close();
 
+        /**
+         * Applies a batch of writes through {@code cfAccessor}, which owns 
the column-family layout.
+         * The default applies the entries one at a time; accessors that can 
write them as a single
+         * batch — or that must stage them instead of writing them — override 
this.
+         */
+        default void putAll(final ColumnFamilyAccessor cfAccessor,

Review Comment:
   Just curious about having a `default` impl -- it seems both 
`DirectDBAccessor` and `TransactionalDBAccessor` actually overwrite it and 
don't use the default. So why do we have it?
   
   Also wondering about the existing `default` impl of `readOnly()` below, 
which does ignore its `isolationLevel` parameter, so wondering if this default 
impl could even be correct to begin with? (Even if it's overwritten only by 
`TransactionalDBAccessor`. Just curious to learn more, as I am not familiar 
with the details of KIP-892 implemenation.



##########
streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java:
##########
@@ -1664,6 +1664,125 @@ public void 
offsetColumnFamilyWritesShouldNotLeakIntoDataIteration() {
         }
     }
 
+    @Test
+    public void putAllShouldStageWritesUntilCommitWhenTransactional() {
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        // An empty RocksDB WriteBatch already reports a fixed header size, so 
the baseline is non-zero.
+        final long emptyBufferBytes = 
rocksDBStore.approximateNumUncommittedBytes();
+
+        final Bytes k1 = new Bytes(stringSerializer.serialize(null, "k1"));
+        final Bytes k2 = new Bytes(stringSerializer.serialize(null, "k2"));
+        rocksDBStore.putAll(List.of(
+            KeyValue.pair(k1, stringSerializer.serialize(null, "v1")),
+            KeyValue.pair(k2, stringSerializer.serialize(null, "v2"))));
+
+        final ReadOnlyKeyValueStore<Bytes, byte[]> uncommitted = 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+        final ReadOnlyKeyValueStore<Bytes, byte[]> committed = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+        // the batch is staged rather than written: it counts towards the 
uncommitted byte total ...
+        assertTrue(rocksDBStore.approximateNumUncommittedBytes() > 
emptyBufferBytes);
+        // ... is visible to the owner and at READ_UNCOMMITTED ...
+        assertEquals("v1", stringDeserializer.deserialize(null, 
rocksDBStore.get(k1)));
+        assertEquals("v2", stringDeserializer.deserialize(null, 
uncommitted.get(k2)));
+        // ... and stays hidden at READ_COMMITTED until the store commits.
+        assertNull(committed.get(k1));
+        assertNull(committed.get(k2));
+
+        rocksDBStore.commit(Map.of());
+
+        assertEquals("v1", stringDeserializer.deserialize(null, 
committed.get(k1)));
+        assertEquals("v2", stringDeserializer.deserialize(null, 
committed.get(k2)));
+    }
+
+    @Test
+    public void putAllShouldBeDiscardedOnRollbackWhenTransactional() {
+        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"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "committed"));
+        rocksDBStore.commit(Map.of());
+
+        rocksDBStore.putAll(List.of(
+            KeyValue.pair(k1, stringSerializer.serialize(null, "rolled-back")),
+            KeyValue.pair(k2, stringSerializer.serialize(null, 
"rolled-back"))));
+        rocksDBStore.dbAccessor.rollbackStagedWrites();
+
+        // an aborted batch must leave nothing behind: the committed value is 
intact and the new key is gone
+        assertEquals("committed", stringDeserializer.deserialize(null, 
rocksDBStore.get(k1)));
+        assertNull(rocksDBStore.get(k2));
+        assertEquals("committed", stringDeserializer.deserialize(null,
+            rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED).get(k1)));
+        
assertNull(rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED).get(k2));

Review Comment:
   Not sure why we test READ_COMMITTED here? For this isolation level, even if 
the write is pending, it's not visible, right? Isn't the more interesting case 
READ_UNCOMMITTED, to verify that the buffer was dropped?



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java:
##########
@@ -512,10 +512,17 @@ public synchronized byte[] putIfAbsent(final Bytes key,
 
     @Override
     public void putAll(final List<KeyValue<Bytes, byte[]>> entries) {
+        Objects.requireNonNull(entries, "entries cannot be null");
+        // Validate up front so a null key rejects the whole batch. An 
accessor may apply the entries
+        // one at a time, and failing part-way through would otherwise leave 
the batch half-applied.
+        for (final KeyValue<Bytes, byte[]> entry : entries) {
+            Objects.requireNonNull(entry, "entry cannot be null");
+            Objects.requireNonNull(entry.key, "key cannot be null");
+        }
+        validateStoreOpen();

Review Comment:
   Why do we need to do this? We didn't have this in the existing code either.



##########
streams/src/main/java/org/apache/kafka/streams/state/internals/RocksDBStore.java:
##########
@@ -1251,6 +1280,23 @@ public void deleteRange(final ColumnFamilyHandle 
columnFamily, final byte[] from
             buffer.stageDeleteRange(columnFamily, Bytes.wrap(from), 
Bytes.wrap(to));
         }
 
+        @Override
+        public void putAll(final ColumnFamilyAccessor cfAccessor,
+                           final List<KeyValue<Bytes, byte[]>> entries) {
+            // Batch writes must be staged like single-key puts, or they would 
land in the store
+            // uncommitted: visible at READ_COMMITTED, surviving a rollback, 
and uncounted by

Review Comment:
   > visible at READ_COMMITTED
   
   This is confusing me? If we stage something, should it not be invisible 
until committed for READ_COMMITTED mode?
   
   > surviving a rollback
   
   Similar. If we have an error and roll-back, should we not throw away all 
state data?
   
   > uncounted by approximateNumUncommittedBytes()
   
   Same. Seems off if stage data is not included in "uncommitted bytes"?



##########
streams/src/test/java/org/apache/kafka/streams/state/internals/RocksDBStoreTest.java:
##########
@@ -1664,6 +1664,125 @@ public void 
offsetColumnFamilyWritesShouldNotLeakIntoDataIteration() {
         }
     }
 
+    @Test
+    public void putAllShouldStageWritesUntilCommitWhenTransactional() {
+        rocksDBStore.close();
+        final InternalMockProcessorContext<?, ?> eosContext = 
getTransactionalEOSProcessorContext(dir);
+        rocksDBStore = getRocksDBStore();
+        rocksDBStore.init(eosContext, rocksDBStore);
+
+        // An empty RocksDB WriteBatch already reports a fixed header size, so 
the baseline is non-zero.
+        final long emptyBufferBytes = 
rocksDBStore.approximateNumUncommittedBytes();
+
+        final Bytes k1 = new Bytes(stringSerializer.serialize(null, "k1"));
+        final Bytes k2 = new Bytes(stringSerializer.serialize(null, "k2"));
+        rocksDBStore.putAll(List.of(
+            KeyValue.pair(k1, stringSerializer.serialize(null, "v1")),
+            KeyValue.pair(k2, stringSerializer.serialize(null, "v2"))));
+
+        final ReadOnlyKeyValueStore<Bytes, byte[]> uncommitted = 
rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED);
+        final ReadOnlyKeyValueStore<Bytes, byte[]> committed = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED);
+
+        // the batch is staged rather than written: it counts towards the 
uncommitted byte total ...
+        assertTrue(rocksDBStore.approximateNumUncommittedBytes() > 
emptyBufferBytes);
+        // ... is visible to the owner and at READ_UNCOMMITTED ...
+        assertEquals("v1", stringDeserializer.deserialize(null, 
rocksDBStore.get(k1)));
+        assertEquals("v2", stringDeserializer.deserialize(null, 
uncommitted.get(k2)));
+        // ... and stays hidden at READ_COMMITTED until the store commits.
+        assertNull(committed.get(k1));
+        assertNull(committed.get(k2));
+
+        rocksDBStore.commit(Map.of());
+
+        assertEquals("v1", stringDeserializer.deserialize(null, 
committed.get(k1)));
+        assertEquals("v2", stringDeserializer.deserialize(null, 
committed.get(k2)));
+    }
+
+    @Test
+    public void putAllShouldBeDiscardedOnRollbackWhenTransactional() {
+        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"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "committed"));
+        rocksDBStore.commit(Map.of());
+
+        rocksDBStore.putAll(List.of(
+            KeyValue.pair(k1, stringSerializer.serialize(null, "rolled-back")),
+            KeyValue.pair(k2, stringSerializer.serialize(null, 
"rolled-back"))));
+        rocksDBStore.dbAccessor.rollbackStagedWrites();
+
+        // an aborted batch must leave nothing behind: the committed value is 
intact and the new key is gone
+        assertEquals("committed", stringDeserializer.deserialize(null, 
rocksDBStore.get(k1)));
+        assertNull(rocksDBStore.get(k2));
+        assertEquals("committed", stringDeserializer.deserialize(null,
+            rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED).get(k1)));
+        
assertNull(rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED).get(k2));
+    }
+
+    @Test
+    public void putAllShouldStageTombstonesWhenTransactional() {
+        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"));
+        rocksDBStore.put(k1, stringSerializer.serialize(null, "v1"));
+        rocksDBStore.put(k2, stringSerializer.serialize(null, "v2"));
+        rocksDBStore.commit(Map.of());
+
+        // a null value in the batch is a delete, and must be staged like any 
other write
+        rocksDBStore.putAll(Arrays.asList(
+            KeyValue.pair(k1, null),
+            KeyValue.pair(k2, stringSerializer.serialize(null, 
"v2-updated"))));
+
+        final ReadOnlyKeyValueStore<Bytes, byte[]> committed = 
rocksDBStore.readOnly(IsolationLevel.READ_COMMITTED);
+        assertNull(rocksDBStore.get(k1));
+        assertEquals("v1", stringDeserializer.deserialize(null, 
committed.get(k1)));
+        assertEquals("v2-updated", stringDeserializer.deserialize(null,
+            rocksDBStore.readOnly(IsolationLevel.READ_UNCOMMITTED).get(k2)));

Review Comment:
   Should we also test uncommitted k1 (or would it be redundant to 
`assertNull(rocksDBStore.get(k1));` ?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to