[ 
https://issues.apache.org/jira/browse/CASSANDRA-21671?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
 ]

Caleb Rackliffe updated CASSANDRA-21671:
----------------------------------------
    Attachment: ci_summary-1.html
                result_details.tar-1.gz

> Fix compressed scan read-ahead buffer Block leak and chunk-cache pollution by 
> scans
> -----------------------------------------------------------------------------------
>
>                 Key: CASSANDRA-21671
>                 URL: https://issues.apache.org/jira/browse/CASSANDRA-21671
>             Project: Apache Cassandra
>          Issue Type: Bug
>          Components: Local/Compaction
>            Reporter: Jon Haddad
>            Assignee: Jon Haddad
>            Priority: Normal
>             Fix For: 5.0.x, 6.0-alpha3, 7.x
>
>          Time Spent: 3h 50m
>  Remaining Estimate: 0h
>
> Under sustained compaction with the BTI SSTable format and Direct IO reads, 
> compaction tasks abort with two distinct {{{}IllegalArgumentException{}}}s 
> that share one root cause. Both are new in 6.0 and unreleased.
> {{ThreadLocalReadAheadBuffer}} caches its per-thread read-ahead {{Block}} in 
> a _static_ {{FastThreadLocal<Map<String, Block>>}} keyed by file path. The 
> cached {{Block}} is shared across {{ThreadLocalReadAheadBuffer}} instances 
> that do not have identical buffer semantics. On a single compaction thread, a 
> second instance over the same file path reuses an already-populated {{Block}} 
> and skips first-time setup. This produces two failures.
> h3. Failure 1: {{newLimit < 0: (-1 < 0)}}
> {{bufferSize}} is a per-instance field initialised to {{{}-1{}}}, assigned 
> only inside the {{block.buffer == null}} branch of {{{}getBlock(){}}}. A 
> second instance that reuses a cached {{Block}} never enters that branch, so 
> its {{bufferSize}} stays {{{}-1{}}}. In {{{}fill(){}}}, {{Math.min(remaining, 
> -1)}} is {{{}-1{}}}, which is passed to {{ByteBuffer.limit(-1)}} and throws.
> {{{}java.lang.IllegalArgumentException: newLimit < 0: (-1 < 0)
>     at java.base/java.nio.MappedByteBuffer.limit(MappedByteBuffer.java:73)
>     at 
> org.apache.cassandra.io.util.ThreadLocalReadAheadBuffer.loadBlock(ThreadLocalReadAheadBuffer.java:130)
>     at 
> org.apache.cassandra.io.util.ThreadLocalReadAheadBuffer.fill(ThreadLocalReadAheadBuffer.java:119)
>     at 
> org.apache.cassandra.io.util.CompressedChunkReader$ScanCompressedReader.read(CompressedChunkReader.java:229)
>     ...
>     at 
> org.apache.cassandra.io.sstable.SSTableRewriter.moveStarts(SSTableRewriter.java:240)
>     at 
> org.apache.cassandra.io.sstable.format.bti.BtiTableWriter.lambda$openEarly$1(BtiTableWriter.java:130)
>     at 
> org.apache.cassandra.db.compaction.CompactionTask.runMayThrow(CompactionTask.java:292){}}}Copy
> BTI compaction early-open triggers it: {{SSTableRewriter.moveStarts}} -> 
> {{BtiTableReader.firstKeyBeyond}} opens a fresh reader over a file the same 
> compaction thread already read through.
> Introduced by {{6f5fe8c06d5}} (2026-01-16, "Add Direct IO support for 
> compaction reads"), which made {{bufferSize}} a lazily initialised instance 
> field inside the {{buffer == null}} branch. The read-ahead scaffold is 
> {{a09d5240abc}} (2024-04-17).
> h3. Failure 2: {{Cannot clean a buffer with no cleaner}}
> Once Failure 1 is fixed, a second exception surfaces (it was previously 
> suppressed under the {{newLimit}} primary during failed-reader cleanup).
> {{{}java.lang.IllegalArgumentException: Cannot clean a buffer with no cleaner 
> and attachment type
> java.nio.DirectByteBuffer; this buffer does not own its memory.
> For slices/duplicates, resolve to the root allocation before calling clean()
>     at org.apache.cassandra.utils.memory.MemoryUtil.clean(MemoryUtil.java:346)
>     at 
> org.apache.cassandra.io.util.ThreadLocalReadAheadBuffer.cleanBuffer(ThreadLocalReadAheadBuffer.java:174)
>     at 
> org.apache.cassandra.io.util.ThreadLocalReadAheadBuffer.clear(ThreadLocalReadAheadBuffer.java:167)
>     at 
> org.apache.cassandra.io.util.CompressedChunkReader$ScanCompressedReader.deallocateResources(CompressedChunkReader.java:260)
>     ...
>     at 
> org.apache.cassandra.io.util.RandomAccessReader.close(RandomAccessReader.java:189){}}}Copy
> {{ThreadLocalReadAheadBuffer}} (base, built by 
> {{{}CompressedChunkReader.Standard{}}}) allocates a direct buffer that owns 
> its memory. {{DirectThreadLocalReadAheadBuffer}} (built by 
> {{{}CompressedChunkReader.Direct{}}}) allocates an aligned _slice_ that does 
> not own its memory and overrides {{cleanBuffer()}} to free the attachment. 
> Because both can share the same cached {{Block}} on one thread, a base 
> instance can free a slice allocated by a Direct instance. Cleanup dispatches 
> on the cleaning instance's class, not the buffer's actual ownership, so the 
> base {{cleanBuffer()}} calls {{{}MemoryUtil.clean(slice){}}}; the slice has 
> no cleaner and a {{DirectByteBuffer}} attachment, so the hardened 
> {{MemoryUtil.clean()}} throws. Pre-hardening, this was a latent unsafe free / 
> memory-corruption risk.
> h2. Environment
>  * 3-node cluster, i4i-class local NVMe.
>  * {{sstable.selected_format: bti}}
>  * {{{}compaction_read_disk_access_mode: direct{}}}, 
> {{background_write_disk_access_mode: direct}}
>  * {{{}default_compaction: UnifiedCompactionStrategy{}}}, LZ4 compression.
>  * Reproduced on all nodes; thousands of occurrences per node over multi-hour 
> windows.
> h2. Steps to reproduce
>  # Configure BTI format and the Direct IO disk-access modes above.
>  # Write enough data to a compressed table to trigger multi-input UCS 
> compactions large enough for early-open 
> ({{{}SSTableRewriter.maybeReopenEarly{}}}) to fire.
>  # Watch {{system.log}} for {{newLimit < 0}} on {{CompactionExecutor}} 
> threads; after the {{bufferSize}} fix, watch for {{{}Cannot clean a buffer 
> with no cleaner{}}}.
> h2. Fix
> Two minimal changes in {{{}ThreadLocalReadAheadBuffer{}}}:
>  # Move the {{bufferSize}} initialisation out of the {{block.buffer == null}} 
> guard, so any instance that observes a {{Block}} (fresh or reused) 
> initialises {{bufferSize}} from the buffer capacity. The capacity is 
> identical for every instance over the same file.
>  # Base {{cleanBuffer()}} resolves a direct buffer that has no cleaner and a 
> {{ByteBuffer}} attachment to its root allocation before calling 
> {{{}MemoryUtil.clean{}}}. The redundant 
> {{DirectThreadLocalReadAheadBuffer.cleanBuffer()}} override is deleted; the 
> subclass inherits the single correct base implementation.
>  



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to