Mykyta Bozhenko created KAFKA-21410:
---------------------------------------

             Summary: Log cleaner ignores a committed tombstone after an 
earlier aborted transaction of the same producer; the deleted key comes back
                 Key: KAFKA-21410
                 URL: https://issues.apache.org/jira/browse/KAFKA-21410
             Project: Kafka
          Issue Type: Bug
          Components: log
    Affects Versions: 4.4.0
         Environment: macOS arm64, OpenJDK 17, single KRaft node; also 1 
controller + 3 brokers (RF 3) on Linux (Docker) and OpenBSD 8.0
            Reporter: Mykyta Bozhenko
            Assignee: Mykyta Bozhenko
         Attachments: LogCleanerTest.patch, cluster-observations.txt, 
fix.patch, repro-output.txt, repro.sh, unit-test-output.txt

On a compacted topic written by transactional producers, the log cleaner can 
ignore a tombstone (or a newer value) that a producer committed. It keeps the 
key's older value, removes the tombstone after
delete.retention.ms, and from then on a consumer reading from the beginning 
gets the deleted key back with its old value. Nothing is logged at WARN or 
ERROR. Each replica runs its own cleaner; in a run with
three replicas all three ended up with the old value 
([^cluster-observations.txt]).

Expected, per the compaction guarantee in the documentation 
(https://kafka.apache.org/44/design/design/#what-guarantees-does-log-compaction-provide):
 "Any consumer progressing from the start of the log will
see at least the final state of all records in the order they were written".
Actual: the consumer reads the old value.

It happens when a cleaning pass stops in the middle of a segment because its 
offset map is full (log.cleaner.dedupe.buffer.size; about 5 million keys per 
cleaner thread with the default 128 MiB), the same
producer id has an aborted transaction that ended earlier in that segment, 
before the position where the next pass starts reading, and the next pass 
reaches a later committed batch of that producer. Typical
case: exactly-once writers such as Kafka Streams changelogs with 
exactly_once_v2, where aborts follow rebalances, timeouts or crashes.

h3. Reproduction (attached)

* [^repro.sh]: one broker+controller from a Kafka distribution, about 3 
minutes. {{KAFKA_HOME=<kafka dir> bash repro.sh p1}} shows the bug; {{p2}} is 
the control, where a different producer commits the
tombstone. Output on 4.4.0: [^repro-output.txt]. With p1 the consumer returns 
K=v1 although K was deleted; with p2 it does not return K.
* [^LogCleanerTest.patch]: a unit test for trunk 8aa57006ff. Run {{git apply 
LogCleanerTest.patch}} and {{./gradlew :storage:test --tests
'org.apache.kafka.storage.internals.log.LogCleanerTest.testCommittedTombstone*'}}.
 The same-producer case fails, the other-producer case passes: 
[^unit-test-output.txt].

h3. Cause

Cleaner#buildOffsetMap seeds CleanedTransactionMetadata with 
log.collectAbortedTransactions(start, end)
([Cleaner.java#L730-L732|https://github.com/apache/kafka/blob/8aa57006ff5da4844776d1047bfee1619faea47f/storage/src/main/java/org/apache/kafka/storage/internals/log/Cleaner.java#L730-L732]),
 and
UnifiedLog#collectAbortedTransactions reads the transaction indexes from the 
segment at or below start but uses the log start offset, not start, as the 
lower bound
([UnifiedLog.java#L1676-L1678|https://github.com/apache/kafka/blob/8aa57006ff5da4844776d1047bfee1619faea47f/storage/src/main/java/org/apache/kafka/storage/internals/log/UnifiedLog.java#L1676-L1678],
[LocalLog.java#L628-L633|https://github.com/apache/kafka/blob/8aa57006ff5da4844776d1047bfee1619faea47f/storage/src/main/java/org/apache/kafka/storage/internals/log/LocalLog.java#L628-L633]).
 So an aborted
transaction that ended before start is seeded as ongoing. Reading starts at the 
offset index entry at or below start
([Cleaner.java#L788|https://github.com/apache/kafka/blob/8aa57006ff5da4844776d1047bfee1619faea47f/storage/src/main/java/org/apache/kafka/storage/internals/log/Cleaner.java#L788]);
 if the ABORT marker lies
before that position it is never read, and only an ABORT marker clears the 
producer
([CleanedTransactionMetadata.java#L83-L155|https://github.com/apache/kafka/blob/8aa57006ff5da4844776d1047bfee1619faea47f/storage/src/main/java/org/apache/kafka/storage/internals/log/CleanedTransactionMetada
ta.java#L83-L155]). Every later transactional batch of that producer, committed 
or not, is then treated as aborted and its keys are left out of the offset map
([Cleaner.java#L809-L814|https://github.com/apache/kafka/blob/8aa57006ff5da4844776d1047bfee1619faea47f/storage/src/main/java/org/apache/kafka/storage/internals/log/Cleaner.java#L809-L814]),
 so the older
value of the key is kept. The lower bound has been the log start offset since 
KAFKA-5196 (0.11.0.0); KAFKA-13727 fixed the upper bound of the same 
partial-pass case in Cleaner#cleanSegments.

h3. Proposed fix

[^fix.patch]: in Cleaner#buildOffsetMap, seed only the aborted transactions 
with lastOffset() >= start. Cleaner#cleanSegments keeps the aborted 
transactions of the whole segment (it rebuilds the transaction
index of the new segment, see KAFKA-13727), so 
UnifiedLog#collectAbortedTransactions does not change. With the patch, both new 
tests and LogCleanerTest, LogCleanerManagerTest and LogCleanerIntegrationTest
pass.



--
This message was sent by Atlassian Jira
(v10.3.26#10030026)

Reply via email to