Add Direct IO support for compaction reads

This change adds support for using O_DIRECT (direct I/O) when reading SSTables
during compaction. Direct I/O bypasses the OS page cache, which can be beneficial
for compaction workloads where data is typically read once and not accessed again
soon after.

Key changes:
- Add DiskAccessMode.direct option for scan operations
- Introduce DirectThreadLocalByteBufferHolder and DirectThreadLocalReadAheadBuffer
  for aligned buffer management required by O_DIRECT
- Add ByteBufferHolder interface to abstract buffer management
- Modify FileHandle to support building direct I/O readers via toBuilder()
- Add startup check to verify Direct IO support on configured data directories
- Enable read-ahead for uncompressed data in Direct CompressedChunkReader
- Move direct IO support logic into FileHandle (from SSTableReader)
- Add comprehensive tests for direct I/O chunk readers and buffer holders

patch by Sam Lightfoot; reviewed by Ariel Weisberg and Maxwell Guo for CASSANDRA-19987
This commit is contained in:
samlightfoot 2026-01-16 10:43:08 +00:00 committed by Ariel Weisberg
parent 940739a488
commit 6f5fe8c06d
43 changed files with 1665 additions and 233 deletions

View File

@ -1,4 +1,5 @@
5.1
* Direct I/O support for compaction reads (CASSANDRA-19987)
* Support custom StartupCheck implementations via SPI (CASSANDRA-21093)
* Make sstableexpiredblockers support human-readable output with SSTable sizes (CASSANDRA-20448)
* Enhance nodetool compactionhistory to report more compaction properities (CASSANDRA-20081)

View File

@ -688,6 +688,11 @@ commitlog_segment_size: 32MiB
# The default setting is legacy when the storage compatibility is set to 4 or auto otherwise.
commitlog_disk_access_mode: legacy
# Set the disk access mode for reading SSTables during compaction. The allowed values are:
# - auto: inherit from disk_access_mode (default)
# - direct: use direct I/O for compaction reads, bypassing the OS page cache
# compaction_read_disk_access_mode: auto
# Compression to apply to SSTables as they flush for compressed tables.
# Note that tables without compression enabled do not respect this flag.
#

View File

@ -695,6 +695,11 @@ commitlog_segment_size: 32MiB
# The default setting is legacy when the storage compatibility is set to 4 or auto otherwise.
commitlog_disk_access_mode: auto
# Set the disk access mode for reading SSTables during compaction. The allowed values are:
# - auto: inherit from disk_access_mode (default)
# - direct: use direct I/O for compaction reads, bypassing the OS page cache
# compaction_read_disk_access_mode: auto
# Compression to apply to SSTables as they flush for compressed tables.
# Note that tables without compression enabled do not respect this flag.
#

View File

@ -428,6 +428,7 @@ public class Config
public FlushCompression flush_compression = FlushCompression.fast;
public int commitlog_max_compression_buffers_in_pool = 3;
public DiskAccessMode commitlog_disk_access_mode = DiskAccessMode.legacy;
public DiskAccessMode compaction_read_disk_access_mode = DiskAccessMode.auto;
@Replaces(oldName = "periodic_commitlog_sync_lag_block_in_ms", converter = Converters.MILLIS_DURATION_INT, deprecated = true)
public DurationSpec.IntMillisecondsBound periodic_commitlog_sync_lag_block;
public TransparentDataEncryptionOptions transparent_data_encryption_options = new TransparentDataEncryptionOptions();

View File

@ -221,6 +221,8 @@ public class DatabaseDescriptor
private static DiskAccessMode commitLogWriteDiskAccessMode;
private static DiskAccessMode compactionReadDiskAccessMode;
private static AbstractCryptoProvider cryptoProvider;
private static IAuthenticator authenticator;
private static IAuthorizer authorizer;
@ -662,6 +664,21 @@ public class DatabaseDescriptor
}
logger.info("DiskAccessMode is {}, indexAccessMode is {}", conf.disk_access_mode, indexAccessMode);
if (DiskAccessMode.auto == conf.compaction_read_disk_access_mode)
{
compactionReadDiskAccessMode = conf.disk_access_mode;
}
else if (DiskAccessMode.direct == conf.compaction_read_disk_access_mode)
{
compactionReadDiskAccessMode = DiskAccessMode.direct;
}
else
{
throw new IllegalArgumentException("Unsupported disk access mode for compaction_read_disk_access_mode " +
"(options: direct/auto) " + conf.compaction_read_disk_access_mode);
}
logger.info("compaction_read_disk_access_mode resolved to: {}", compactionReadDiskAccessMode);
/* phi convict threshold for FailureDetector */
if (conf.phi_convict_threshold < 5 || conf.phi_convict_threshold > 16)
{
@ -1773,7 +1790,7 @@ public class DatabaseDescriptor
File commitLogLocationDir = new File(commitLogLocation);
PathUtils.createDirectoriesIfNotExists(commitLogLocationDir.toPath());
directIOSupported = FileUtils.getBlockSize(commitLogLocationDir) > 0;
directIOSupported = FileUtils.isDirectIOSupported(commitLogLocationDir);
}
catch (IOError | ConfigurationException ex)
{
@ -3301,6 +3318,18 @@ public class DatabaseDescriptor
conf.commitlog_segment_size = new DataStorageSpec.IntMebibytesBound(sizeMebibytes);
}
public static DiskAccessMode getCompactionReadDiskAccessMode()
{
return compactionReadDiskAccessMode;
}
@VisibleForTesting
public static void setCompactionReadDiskAccessMode(DiskAccessMode scanDiskAccessMode)
{
compactionReadDiskAccessMode = scanDiskAccessMode;
conf.compaction_read_disk_access_mode = scanDiskAccessMode;
}
/**
* Return commitlog disk access mode.
*/

View File

@ -32,6 +32,7 @@ import com.google.common.collect.ImmutableMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.Directories;
import org.apache.cassandra.db.SerializationHeader;
@ -262,7 +263,7 @@ public abstract class AbstractCompactionStrategy
try
{
for (SSTableReader sstable : sstables)
scanners.add(sstable.getScanner(ranges));
scanners.add(sstable.getScanner(ranges, DatabaseDescriptor.getCompactionReadDiskAccessMode()));
}
catch (Throwable t)
{

View File

@ -1767,7 +1767,7 @@ public class CompactionManager implements CompactionManagerMBean, ICompactionMan
{
rangesToScan = Collections2.filter(ranges, range -> !transientRanges.contains(range));
}
return sstable.getScanner(rangesToScan);
return sstable.getScanner(rangesToScan, DatabaseDescriptor.getCompactionReadDiskAccessMode());
}
@Override
@ -1790,7 +1790,7 @@ public class CompactionManager implements CompactionManagerMBean, ICompactionMan
@Override
public ISSTableScanner getScanner(SSTableReader sstable)
{
return sstable.getScanner();
return sstable.getScanner(DatabaseDescriptor.getCompactionReadDiskAccessMode());
}
@Override

View File

@ -44,6 +44,7 @@ import com.google.common.primitives.Doubles;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.lifecycle.LifecycleTransaction;
import org.apache.cassandra.db.rows.UnfilteredRowIterator;
@ -343,7 +344,7 @@ public class LeveledCompactionStrategy extends AbstractCompactionStrategy
{
// L0 makes no guarantees about overlapping-ness. Just create a direct scanner for each
for (SSTableReader sstable : byLevel.get(level))
scanners.add(sstable.getScanner(ranges));
scanners.add(sstable.getScanner(ranges, DatabaseDescriptor.getCompactionReadDiskAccessMode()));
}
else
{
@ -445,7 +446,7 @@ public class LeveledCompactionStrategy extends AbstractCompactionStrategy
sstableIterator = this.sstables.iterator();
assert sstableIterator.hasNext(); // caller should check intersecting first
SSTableReader currentSSTable = sstableIterator.next();
currentScanner = currentSSTable.getScanner(ranges);
currentScanner = currentSSTable.getScanner(ranges, DatabaseDescriptor.getCompactionReadDiskAccessMode());
}
@Override
@ -498,7 +499,7 @@ public class LeveledCompactionStrategy extends AbstractCompactionStrategy
return endOfData();
}
SSTableReader currentSSTable = sstableIterator.next();
currentScanner = currentSSTable.getScanner(ranges);
currentScanner = currentSSTable.getScanner(ranges, DatabaseDescriptor.getCompactionReadDiskAccessMode());
}
}

View File

@ -76,7 +76,7 @@ public class SSTableIndex extends SharedCloseableImpl
{
Map<IndexComponent, FileHandle> files = new EnumMap<>(IndexComponent.class);
for (IndexComponent c : id.getLiveComponents())
files.put(c, new FileHandle.Builder(id.fileFor(c)).mmapped(true).complete());
files.put(c, new FileHandle.Builder(id.fileFor(c)).mmapped().complete());
List<Segment> segments = RouteIndexFormat.readSegments(files);
files.remove(IndexComponent.SEGMENT).close();
files.remove(IndexComponent.METADATA).close();

View File

@ -269,7 +269,7 @@ public class IndexDescriptor
if (logger.isTraceEnabled())
logger.trace(logMessage("Opening file handle for {} ({})"), file, FBUtilities.prettyPrintMemory(file.length()));
return new FileHandle.Builder(file).mmapped(true).complete();
return new FileHandle.Builder(file).mmapped().complete();
}
catch (Throwable t)
{
@ -291,7 +291,7 @@ public class IndexDescriptor
if (logger.isTraceEnabled())
logger.trace(logMessage("Opening file handle for {} ({})"), file, FBUtilities.prettyPrintMemory(file.length()));
return new FileHandle.Builder(file).mmapped(true).complete();
return new FileHandle.Builder(file).mmapped().complete();
}
catch (Throwable t)
{

View File

@ -84,9 +84,16 @@ public class IndexFileUtils
public IndexInput openBlockingInput(File file)
{
FileHandle fileHandle = new FileHandle.Builder(file).complete();
RandomAccessReader randomReader = fileHandle.createReader();
return IndexInputReader.create(randomReader, fileHandle::close);
try
{
RandomAccessReader randomReader = fileHandle.createReader();
return IndexInputReader.create(randomReader, fileHandle::close);
}
catch (Throwable t)
{
fileHandle.close();
throw t;
}
}
public static ChecksumIndexInput getBufferedChecksumIndexInput(IndexInput indexInput)

View File

@ -29,8 +29,12 @@ public class IndexComponent
{
public static FileHandle.Builder fileBuilder(File file, IOOptions ioOptions, ChunkCache chunkCache)
{
return new FileHandle.Builder(file).withChunkCache(chunkCache)
.mmapped(ioOptions.indexDiskAccessMode);
FileHandle.Builder builder = new FileHandle.Builder(file).withChunkCache(chunkCache);
if (ioOptions.indexDiskAccessMode != null)
builder.withDiskAccessMode(ioOptions.indexDiskAccessMode);
return builder;
}
public static FileHandle.Builder fileBuilder(Component component, SSTable ssTable)

View File

@ -115,6 +115,8 @@ import org.apache.cassandra.utils.concurrent.SharedCloseable;
import org.apache.cassandra.utils.concurrent.UncheckedInterruptedException;
import static org.apache.cassandra.concurrent.ExecutorFactory.Global.executorFactory;
import static org.apache.cassandra.config.Config.DiskAccessMode;
import static org.apache.cassandra.io.util.FileHandle.OnReaderClose;
import static org.apache.cassandra.utils.TimeUUID.unixMicrosToRawTimestamp;
import static org.apache.cassandra.utils.concurrent.BlockingQueues.newBlockingQueue;
import static org.apache.cassandra.utils.concurrent.SharedCloseable.sharedCopyOrNull;
@ -365,7 +367,7 @@ public abstract class SSTableReader extends SSTable implements UnfilteredSource,
public static SSTableReader open(SSTable.Owner owner, Descriptor desc, TableMetadataRef metadata)
{
return open(owner, desc, null, metadata);
return open(owner, desc, null, metadata);
}
public static SSTableReader open(SSTable.Owner owner, Descriptor descriptor, Set<Component> components, TableMetadataRef metadata)
@ -1074,12 +1076,17 @@ public abstract class SSTableReader extends SSTable implements UnfilteredSource,
* @return A Scanner over the full content of the SSTable.
*/
public ISSTableScanner getScanner()
{
return getScanner(dfile.diskAccessMode());
}
public ISSTableScanner getScanner(DiskAccessMode diskAccessMode)
{
PartitionPositionBounds fullRange = getPositionsForFullRange();
if (fullRange != null)
return new SSTableSimpleScanner(this, Collections.singletonList(fullRange));
return new SSTableSimpleScanner(this, Collections.singletonList(fullRange), diskAccessMode);
else
return new SSTableSimpleScanner(this, Collections.emptyList());
return new SSTableSimpleScanner(this, Collections.emptyList(), diskAccessMode);
}
/**
@ -1089,11 +1096,16 @@ public abstract class SSTableReader extends SSTable implements UnfilteredSource,
* @return A Scanner for seeking over the rows of the SSTable.
*/
public ISSTableScanner getScanner(Collection<Range<Token>> ranges)
{
return getScanner(ranges, dfile.diskAccessMode());
}
public ISSTableScanner getScanner(Collection<Range<Token>> ranges, DiskAccessMode diskAccessMode)
{
if (ranges != null)
return new SSTableSimpleScanner(this, getPositionsForRanges(ranges));
return new SSTableSimpleScanner(this, getPositionsForRanges(ranges), diskAccessMode);
else
return getScanner();
return getScanner(diskAccessMode);
}
/**
@ -1104,13 +1116,13 @@ public abstract class SSTableReader extends SSTable implements UnfilteredSource,
*/
public ISSTableScanner getScanner(Iterator<AbstractBounds<PartitionPosition>> boundsIterator)
{
return new SSTableSimpleScanner(this, getPositionsForBoundsIterator(boundsIterator));
return new SSTableSimpleScanner(this, getPositionsForBoundsIterator(boundsIterator), dfile.diskAccessMode());
}
public ISSTableScanner getScanner(AbstractBounds<PartitionPosition> bounds)
{
PartitionPositionBounds positionBounds = getPositionsForBounds(bounds);
return new SSTableSimpleScanner(this, positionBounds == null ? Collections.emptyList() : Collections.singletonList(positionBounds));
return new SSTableSimpleScanner(this, positionBounds == null ? Collections.emptyList() : Collections.singletonList(positionBounds), dfile.diskAccessMode());
}
@ -1418,7 +1430,29 @@ public abstract class SSTableReader extends SSTable implements UnfilteredSource,
public RandomAccessReader openDataReaderForScan()
{
return dfile.createReaderForScan();
return openDataReaderForScan(dfile.diskAccessMode());
}
public RandomAccessReader openDataReaderForScan(DiskAccessMode diskAccessMode)
{
boolean isSameDiskAccessMode = diskAccessMode == dfile.diskAccessMode();
boolean isDirectIONotSupported = diskAccessMode == DiskAccessMode.direct && !dfile.supportsDirectIO();
if (isSameDiskAccessMode || isDirectIONotSupported)
return dfile.createReaderForScan(OnReaderClose.RETAIN_FILE_OPEN);
FileHandle dataFile = dfile.toBuilder()
.withDiskAccessMode(diskAccessMode)
.complete();
try
{
return dataFile.createReaderForScan(OnReaderClose.CLOSE_FILE);
}
catch (Throwable t)
{
dataFile.close();
throw t;
}
}
public void trySkipFileCacheBefore(DecoratedKey key)

View File

@ -35,6 +35,7 @@ import org.apache.cassandra.io.sstable.SSTableIdentityIterator;
import org.apache.cassandra.io.util.RandomAccessReader;
import org.apache.cassandra.schema.TableMetadata;
import static org.apache.cassandra.config.Config.DiskAccessMode;
import static org.apache.cassandra.io.sstable.format.SSTableReader.PartitionPositionBounds;
/// Simple SSTable scanner that reads sequentially through an SSTable without using the index.
@ -71,11 +72,12 @@ implements ISSTableScanner
/// The ranges can be constructed by [SSTableReader#getPositionsForRanges] and similar methods as done by the
/// various [SSTableReader#getScanner] variations.
public SSTableSimpleScanner(SSTableReader sstable,
Collection<PartitionPositionBounds> boundsList)
Collection<PartitionPositionBounds> boundsList,
DiskAccessMode diskAccessMode)
{
assert sstable != null;
this.dfile = sstable.openDataReaderForScan();
this.dfile = sstable.openDataReaderForScan(diskAccessMode);
this.sstable = sstable;
this.tableMetadata = sstable.metadata();
this.sizeInBytes = boundsList.stream().mapToLong(ppb -> ppb.upperPosition - ppb.lowerPosition).sum();

View File

@ -62,7 +62,7 @@ extends SSTableReaderLoadingBuilder<R, B>
dataFileBuilder.bufferSize(bufferSize);
dataFileBuilder.withChunkCache(chunkCache);
dataFileBuilder.mmapped(ioOptions.defaultDiskAccessMode);
dataFileBuilder.withDiskAccessMode(ioOptions.defaultDiskAccessMode);
return dataFileBuilder;
}

View File

@ -380,22 +380,21 @@ public abstract class SortedTableWriter<P extends SortedTablePartitionWriter, I
{
int dataBufferSize = ioOptions.diskOptimizationStrategy.bufferSize(statsMetadata.estimatedPartitionSize.percentile(ioOptions.diskOptimizationEstimatePercentile));
FileHandle dataFile;
try (CompressionMetadata compressionMetadata = compression ? ((CompressedSequentialWriter) dataWriter).open(lengthOverride) : null)
{
dataFile = dataFileBuilder.mmapped(ioOptions.defaultDiskAccessMode)
.withMmappedRegionsCache(mmappedRegionsCache)
.withChunkCache(chunkCache)
.withCompressionMetadata(compressionMetadata)
.bufferSize(dataBufferSize)
.withCrcCheckChance(crcCheckChanceSupplier)
.withLengthOverride(lengthOverride)
.complete();
}
FileHandle dataFile = null;
try
{
try (CompressionMetadata compressionMetadata = compression ? ((CompressedSequentialWriter) dataWriter).open(lengthOverride) : null)
{
dataFile = dataFileBuilder.withDiskAccessMode(ioOptions.defaultDiskAccessMode)
.withMmappedRegionsCache(mmappedRegionsCache)
.withChunkCache(chunkCache)
.withCompressionMetadata(compressionMetadata)
.bufferSize(dataBufferSize)
.withCrcCheckChance(crcCheckChanceSupplier)
.withLengthOverride(lengthOverride)
.complete();
}
if (chunkCache != null)
{
if (lastEarlyOpenLength != 0 && dataFile.dataLength() > lastEarlyOpenLength)

View File

@ -205,7 +205,9 @@ public class BtiTableReaderLoadingBuilder extends SortedTableReaderLoadingBuilde
rowIndexFileBuilder = new FileHandle.Builder(descriptor.fileFor(Components.ROW_INDEX));
rowIndexFileBuilder.withChunkCache(chunkCache);
rowIndexFileBuilder.mmapped(ioOptions.indexDiskAccessMode);
if (ioOptions.indexDiskAccessMode != null)
rowIndexFileBuilder.withDiskAccessMode(ioOptions.indexDiskAccessMode);
return rowIndexFileBuilder;
}
@ -218,7 +220,9 @@ public class BtiTableReaderLoadingBuilder extends SortedTableReaderLoadingBuilde
partitionIndexFileBuilder = new FileHandle.Builder(descriptor.fileFor(Components.PARTITION_INDEX));
partitionIndexFileBuilder.withChunkCache(chunkCache);
partitionIndexFileBuilder.mmapped(ioOptions.indexDiskAccessMode);
if (ioOptions.indexDiskAccessMode != null)
partitionIndexFileBuilder.withDiskAccessMode(ioOptions.indexDiskAccessMode);
return partitionIndexFileBuilder;
}

View File

@ -0,0 +1,28 @@
/*
* 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.cassandra.io.util;
import java.nio.ByteBuffer;
/**
* Holder for reusable thread-local ByteBuffers.
*/
public interface ByteBufferHolder
{
ByteBuffer getBuffer(int size);
}

View File

@ -22,8 +22,11 @@ import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.OpenOption;
import java.nio.file.StandardOpenOption;
import com.sun.nio.file.ExtendedOpenOption;
import org.apache.cassandra.io.FSReadError;
import org.apache.cassandra.utils.NativeLibrary;
import org.apache.cassandra.utils.concurrent.RefCounted;
@ -40,15 +43,22 @@ import org.apache.cassandra.utils.concurrent.SharedCloseableImpl;
*/
public final class ChannelProxy extends SharedCloseableImpl
{
public enum IOMode
{
BUFFERED,
DIRECT
}
private final File file;
private final String filePath;
private final FileChannel channel;
public static FileChannel openChannel(File file)
public static FileChannel openChannel(File file, OpenOption... openOptions)
{
try
{
return FileChannel.open(file.toPath(), StandardOpenOption.READ);
return FileChannel.open(file.toPath(), openOptions);
}
catch (IOException e)
{
@ -56,14 +66,32 @@ public final class ChannelProxy extends SharedCloseableImpl
}
}
private static OpenOption[] openOptions(IOMode ioMode)
{
switch (ioMode)
{
case DIRECT:
return new OpenOption[]{ StandardOpenOption.READ, ExtendedOpenOption.DIRECT };
case BUFFERED:
return new OpenOption[]{ StandardOpenOption.READ };
default:
throw new IllegalArgumentException("Unknown IOMode " + ioMode);
}
}
public ChannelProxy(String path)
{
this (new File(path));
this(new File(path));
}
public ChannelProxy(File file)
{
this(file, openChannel(file));
this(file, IOMode.BUFFERED);
}
public ChannelProxy(File file, IOMode ioMode)
{
this(file, openChannel(file, openOptions(ioMode)));
}
public ChannelProxy(File file, FileChannel channel)

View File

@ -115,10 +115,58 @@ public abstract class CompressedChunkReader extends AbstractReaderFileProxy impl
}
/**
* The returned buffer is only valid until the next call to read(). Callers must consume the data immediately.
*/
ByteBuffer read(CompressionMetadata.Chunk chunk, boolean shouldCheckCrc) throws CorruptBlockException;
}
private static final class DirectRandomAccessReader implements CompressedReader
{
private final ChannelProxy channel;
private final int blockSize;
private final DirectThreadLocalByteBufferHolder bufferHolder;
DirectRandomAccessReader(ChannelProxy ch, int blockSize)
{
this.channel = ch;
this.blockSize = blockSize;
this.bufferHolder = new DirectThreadLocalByteBufferHolder(blockSize);
}
@Override
public ByteBuffer read(CompressionMetadata.Chunk chunk, boolean shouldCheckCrc) throws CorruptBlockException
{
int length = shouldCheckCrc ? chunk.length + Integer.BYTES // length + checksum length
: chunk.length;
long alignedPos = chunk.offset & -blockSize;
int delta = (int) (chunk.offset - alignedPos);
ByteBuffer buffer = bufferHolder.getBuffer(length + delta);
if (channel.read(buffer, alignedPos) < length + delta)
throw new CorruptBlockException(channel.filePath(), chunk);
buffer.position(delta);
buffer.limit(delta + length);
ByteBuffer slice = buffer.slice();
slice.limit(chunk.length); // limit at chunk content end (before CRC)
if (shouldCheckCrc)
{
int checksum = (int) ChecksumType.CRC32.of(slice);
slice.limit(length);
if (slice.getInt() != checksum)
throw new CorruptBlockException(channel.filePath(), chunk);
slice.position(0).limit(chunk.length);
}
return slice;
}
}
private static class RandomAccessCompressedReader implements CompressedReader
{
private final ChannelProxy channel;
@ -155,15 +203,17 @@ public abstract class CompressedChunkReader extends AbstractReaderFileProxy impl
private static class ScanCompressedReader implements CompressedReader
{
private final ChannelProxy channel;
private final ThreadLocalByteBufferHolder bufferHolder;
private final ByteBufferHolder bufferHolder;
private final ThreadLocalReadAheadBuffer readAheadBuffer;
private ScanCompressedReader(ChannelProxy channel, CompressionMetadata metadata, int readAheadBufferSize)
private ScanCompressedReader(ChannelProxy channel, ByteBufferHolder bufferHolder,
ThreadLocalReadAheadBuffer readAheadBuffer)
{
this.channel = channel;
this.bufferHolder = new ThreadLocalByteBufferHolder(metadata.compressor().preferredBufferType());
this.readAheadBuffer = new ThreadLocalReadAheadBuffer(channel, readAheadBufferSize, metadata.compressor().preferredBufferType());
this.bufferHolder = bufferHolder;
this.readAheadBuffer = readAheadBuffer;
}
@Override
@ -216,12 +266,101 @@ public abstract class CompressedChunkReader extends AbstractReaderFileProxy impl
return readAheadBuffer.hasBuffer();
}
@Override
public void close()
{
readAheadBuffer.close();
}
}
public static class Direct extends CompressedChunkReader
{
private final CompressedReader reader;
private final CompressedReader scanReader;
public Direct(ChannelProxy channel, CompressionMetadata metadata, Supplier<Double> crcCheckChanceSupplier)
{
super(channel, metadata, crcCheckChanceSupplier);
int blockSize = FileUtils.getFileBlockSize(channel.file());
this.reader = new DirectRandomAccessReader(channel, blockSize);
int readAheadBufferSize = DatabaseDescriptor.getCompressedReadAheadBufferSize();
this.scanReader = (readAheadBufferSize > 0 && readAheadBufferSize > metadata.chunkLength())
? new ScanCompressedReader(channel,
new DirectThreadLocalByteBufferHolder(blockSize),
new DirectThreadLocalReadAheadBuffer(channel, readAheadBufferSize, blockSize))
: null;
}
@Override
public void readChunk(long position, ByteBuffer uncompressed)
{
assert (position & -uncompressed.capacity()) == position;
assert position <= fileLength;
try
{
CompressionMetadata.Chunk chunk = metadata.chunkFor(position);
boolean shouldCheckCrc = shouldCheckCrc();
uncompressed.clear();
CompressedReader readFrom = (scanReader != null && scanReader.allocated()) ? scanReader : reader;
if (chunk.length < maxCompressedLength)
{
ByteBuffer compressed = readFrom.read(chunk, shouldCheckCrc);
try
{
metadata.compressor().uncompress(compressed, uncompressed);
}
catch (IOException e)
{
throw new CorruptBlockException(channel.filePath(), chunk, e);
}
}
else
{
ByteBuffer buffer = readFrom.read(chunk, shouldCheckCrc);
uncompressed.put(buffer);
}
uncompressed.flip();
}
catch (CorruptBlockException e)
{
// Make sure reader does not see stale data.
uncompressed.position(0).limit(0);
throw new CorruptSSTableException(e, channel.filePath());
}
}
@Override
protected CompressedChunkReader forScan()
{
if (scanReader != null)
scanReader.allocateResources();
return this;
}
@Override
public void releaseUnderlyingResources()
{
if (scanReader != null)
scanReader.deallocateResources();
}
@Override
public void close()
{
reader.close();
if (scanReader != null)
scanReader.close();
super.close();
}
}
public static class Standard extends CompressedChunkReader
{
@ -235,9 +374,12 @@ public abstract class CompressedChunkReader extends AbstractReaderFileProxy impl
int readAheadBufferSize = DatabaseDescriptor.getCompressedReadAheadBufferSize();
scanReader = (readAheadBufferSize > 0 && readAheadBufferSize > metadata.chunkLength())
? new ScanCompressedReader(channel, metadata, readAheadBufferSize) : null;
? new ScanCompressedReader(channel,
new ThreadLocalByteBufferHolder(metadata.compressor().preferredBufferType()),
new ThreadLocalReadAheadBuffer(channel, readAheadBufferSize, metadata.compressor().preferredBufferType())) : null;
}
@Override
protected CompressedChunkReader forScan()
{
if (scanReader != null)
@ -282,6 +424,7 @@ public abstract class CompressedChunkReader extends AbstractReaderFileProxy impl
}
else
{
// Read directly into destination buffer for zero-copy uncompressed path
uncompressed.position(0).limit(chunk.length);
if (channel.read(uncompressed, chunk.offset) != chunk.length)
throw new CorruptBlockException(channel.filePath(), chunk);

View File

@ -0,0 +1,79 @@
/*
* 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.cassandra.io.util;
import java.nio.ByteBuffer;
import java.util.concurrent.ConcurrentHashMap;
import org.agrona.BitUtil;
import org.agrona.BufferUtil;
import org.apache.cassandra.utils.memory.MemoryUtil;
import io.netty.util.concurrent.FastThreadLocal;
import sun.nio.ch.DirectBuffer;
/**
* Thread-local holder for block-aligned direct ByteBuffers used in Direct I/O operations.
* <p>
* Buffers are shared across all holders with the same block size and reused per-thread,
* growing as needed. This mirrors {@link ThreadLocalByteBufferHolder}'s design.
*/
public final class DirectThreadLocalByteBufferHolder implements ByteBufferHolder
{
private static final ConcurrentHashMap<Integer, FastThreadLocal<ByteBuffer>> buffersByBlockSize = new ConcurrentHashMap<>();
private final FastThreadLocal<ByteBuffer> threadLocal;
private final int blockSize;
public DirectThreadLocalByteBufferHolder(int blockSize)
{
this.blockSize = blockSize;
this.threadLocal = buffersByBlockSize.computeIfAbsent(blockSize, k -> new FastThreadLocal<>());
}
@Override
public ByteBuffer getBuffer(int size)
{
int alignedSize = BitUtil.align(size, blockSize);
ByteBuffer buffer = threadLocal.get();
if (buffer != null && buffer.capacity() >= alignedSize)
{
buffer.clear().limit(alignedSize);
return buffer;
}
if (buffer != null)
cleanBuffer(buffer);
buffer = BufferUtil.allocateDirectAligned(alignedSize, blockSize);
threadLocal.set(buffer);
return buffer;
}
private static void cleanBuffer(ByteBuffer buffer)
{
// Aligned buffers are slices; clean the backing buffer (attachment)
DirectBuffer db = (DirectBuffer) buffer;
ByteBuffer attachment = (ByteBuffer) db.attachment();
MemoryUtil.clean(attachment != null ? attachment : buffer);
}
}

View File

@ -0,0 +1,49 @@
/*
* 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.cassandra.io.util;
import java.nio.ByteBuffer;
import org.agrona.BitUtil;
import org.agrona.BufferUtil;
import org.apache.cassandra.io.sstable.CorruptSSTableException;
public final class DirectThreadLocalReadAheadBuffer extends ThreadLocalReadAheadBuffer
{
private final int blockSize;
public DirectThreadLocalReadAheadBuffer(ChannelProxy channel, int bufferSize, int blockSize)
{
super(channel, () -> BufferUtil.allocateDirectAligned(BitUtil.align(bufferSize, blockSize), blockSize));
this.blockSize = blockSize;
}
@Override
protected void loadBlock(ByteBuffer blockBuffer, long blockPosition, int sizeToRead)
{
int alignedSizeToRead = BitUtil.align(sizeToRead, blockSize);
blockBuffer.limit(alignedSizeToRead);
if (channel.read(blockBuffer, blockPosition) < sizeToRead)
throw new CorruptSSTableException(null, channel.filePath());
}
}

View File

@ -25,7 +25,6 @@ import com.google.common.annotations.VisibleForTesting;
import com.google.common.util.concurrent.RateLimiter;
import org.apache.cassandra.cache.ChunkCache;
import org.apache.cassandra.config.Config;
import org.apache.cassandra.io.compress.BufferType;
import org.apache.cassandra.io.compress.CompressionMetadata;
import org.apache.cassandra.utils.NativeLibrary;
@ -34,6 +33,8 @@ import org.apache.cassandra.utils.concurrent.Ref;
import org.apache.cassandra.utils.concurrent.RefCounted;
import org.apache.cassandra.utils.concurrent.SharedCloseableImpl;
import static org.apache.cassandra.config.Config.DiskAccessMode;
/**
* {@link FileHandle} provides access to a file for reading, including the ones written by various {@link SequentialWriter}
* instances, and it is typically used by {@link org.apache.cassandra.io.sstable.format.SSTableReader}.
@ -48,6 +49,13 @@ import org.apache.cassandra.utils.concurrent.SharedCloseableImpl;
*/
public class FileHandle extends SharedCloseableImpl
{
public enum OnReaderClose
{
CLOSE_FILE,
RETAIN_FILE_OPEN
}
public final ChannelProxy channel;
public final long onDiskLength;
@ -62,17 +70,41 @@ public class FileHandle extends SharedCloseableImpl
*/
private final Optional<CompressionMetadata> compressionMetadata;
private final DiskAccessMode diskAccessMode;
// Properties to support unbuilding via toBuilder
private final ChunkCache chunkCache;
private final MmappedRegionsCache mmappedRegionsCache;
private final Supplier<Double> crcCheckChanceSupplier;
private final long lengthOverride;
private final int bufferSize;
private final BufferType bufferType;
private FileHandle(Cleanup cleanup,
ChannelProxy channel,
RebuffererFactory rebuffererFactory,
CompressionMetadata compressionMetadata,
long onDiskLength)
long onDiskLength,
DiskAccessMode diskAccessMode,
ChunkCache chunkCache,
MmappedRegionsCache mmappedRegionsCache,
Supplier<Double> crcCheckChanceSupplier,
long lengthOverride,
int bufferSize,
BufferType bufferType)
{
super(cleanup);
this.rebuffererFactory = rebuffererFactory;
this.channel = channel;
this.compressionMetadata = Optional.ofNullable(compressionMetadata);
this.onDiskLength = onDiskLength;
this.diskAccessMode = diskAccessMode;
this.chunkCache = chunkCache;
this.mmappedRegionsCache = mmappedRegionsCache;
this.crcCheckChanceSupplier = crcCheckChanceSupplier;
this.lengthOverride = lengthOverride;
this.bufferSize = bufferSize;
this.bufferType = bufferType;
}
private FileHandle(FileHandle copy)
@ -82,6 +114,26 @@ public class FileHandle extends SharedCloseableImpl
rebuffererFactory = copy.rebuffererFactory;
compressionMetadata = copy.compressionMetadata;
onDiskLength = copy.onDiskLength;
diskAccessMode = copy.diskAccessMode;
chunkCache = copy.chunkCache;
mmappedRegionsCache = copy.mmappedRegionsCache;
crcCheckChanceSupplier = copy.crcCheckChanceSupplier;
lengthOverride = copy.lengthOverride;
bufferSize = copy.bufferSize;
bufferType = copy.bufferType;
}
public Builder toBuilder()
{
return new FileHandle.Builder(file())
.withDiskAccessMode(diskAccessMode)
.withCrcCheckChance(crcCheckChanceSupplier)
.withMmappedRegionsCache(mmappedRegionsCache)
.withCompressionMetadata(compressionMetadata.orElse(null))
.withLengthOverride(lengthOverride)
.withChunkCache(chunkCache)
.bufferSize(bufferSize)
.bufferType(bufferType);
}
/**
@ -112,6 +164,16 @@ public class FileHandle extends SharedCloseableImpl
return compressionMetadata;
}
public DiskAccessMode diskAccessMode()
{
return diskAccessMode;
}
public boolean supportsDirectIO()
{
return FileUtils.isDirectIOSupported(file()) && compressionMetadata.isPresent();
}
@Override
public void addTo(Ref.IdentityCollection identities)
{
@ -134,9 +196,9 @@ public class FileHandle extends SharedCloseableImpl
return createReader(null);
}
public RandomAccessReader createReaderForScan()
public RandomAccessReader createReaderForScan(OnReaderClose onReaderClose)
{
return createReader(null, true);
return createReader(null, true, onReaderClose);
}
/**
@ -153,7 +215,20 @@ public class FileHandle extends SharedCloseableImpl
public RandomAccessReader createReader(RateLimiter limiter, boolean forScan)
{
return new RandomAccessReader(instantiateRebufferer(limiter, forScan));
return createReader(limiter, forScan, OnReaderClose.RETAIN_FILE_OPEN);
}
public RandomAccessReader createReader(RateLimiter limiter, boolean forScan, OnReaderClose onReaderClose)
{
if (onReaderClose == OnReaderClose.CLOSE_FILE)
{
return new RandomAccessReader.RandomAccessReaderWithOwnFile(instantiateRebufferer(limiter, forScan), this);
}
else if (onReaderClose == OnReaderClose.RETAIN_FILE_OPEN)
{
return new RandomAccessReader(instantiateRebufferer(limiter, forScan));
}
throw new IllegalArgumentException("Unknown close policy: " + onReaderClose);
}
public FileDataInput createReader(long position)
@ -272,7 +347,7 @@ public class FileHandle extends SharedCloseableImpl
private ChunkCache chunkCache;
private int bufferSize = RandomAccessReader.DEFAULT_BUFFER_SIZE;
private BufferType bufferType = BufferType.OFF_HEAP;
private boolean mmapped = false;
private DiskAccessMode diskAccessMode = DiskAccessMode.standard;
private long lengthOverride = -1;
private MmappedRegionsCache mmappedRegionsCache;
@ -314,21 +389,15 @@ public class FileHandle extends SharedCloseableImpl
return this;
}
/**
* Set whether to use mmap for reading
*
* @param mmapped true if using mmap
* @return this instance
*/
public Builder mmapped(boolean mmapped)
public Builder mmapped()
{
this.mmapped = mmapped;
withDiskAccessMode(DiskAccessMode.mmap);
return this;
}
public Builder mmapped(Config.DiskAccessMode diskAccessMode)
public Builder withDiskAccessMode(DiskAccessMode diskAccessMode)
{
this.mmapped = diskAccessMode == Config.DiskAccessMode.mmap;
this.diskAccessMode = diskAccessMode;
return this;
}
@ -380,7 +449,28 @@ public class FileHandle extends SharedCloseableImpl
*/
public FileHandle complete()
{
return complete(ChannelProxy::new);
return complete(file -> new ChannelProxy(file, ioMode()));
}
private ChannelProxy.IOMode ioMode()
{
switch (diskAccessMode)
{
case mmap:
case standard:
case auto:
case legacy:
case mmap_index_only:
// For mmap and standard, BUFFERED is the correct mode.
// For auto/legacy/mmap_index_only, these are meta-modes that should normally be resolved
// during DatabaseDescriptor initialization. In client/tool mode where full initialization
// didn't occur, BUFFERED (standard) is the safe default.
return ChannelProxy.IOMode.BUFFERED;
case direct:
return ChannelProxy.IOMode.DIRECT;
default:
throw new AssertionError("Unhandled diskAccessMode: " + diskAccessMode);
}
}
@VisibleForTesting
@ -402,7 +492,7 @@ public class FileHandle extends SharedCloseableImpl
{
rebuffererFactory = new EmptyRebufferer(channel);
}
else if (mmapped)
else if (DiskAccessMode.mmap == diskAccessMode)
{
if (compressionMetadata != null)
{
@ -421,7 +511,16 @@ public class FileHandle extends SharedCloseableImpl
{
if (compressionMetadata != null)
{
rebuffererFactory = maybeCached(new CompressedChunkReader.Standard(channel, compressionMetadata, crcCheckChanceSupplier));
final CompressedChunkReader compressedChunkReader;
if (DiskAccessMode.direct == diskAccessMode)
{
compressedChunkReader = new CompressedChunkReader.Direct(channel, compressionMetadata, crcCheckChanceSupplier);
}
else
{
compressedChunkReader = new CompressedChunkReader.Standard(channel, compressionMetadata, crcCheckChanceSupplier);
}
rebuffererFactory = maybeCached(compressedChunkReader);
}
else
{
@ -429,10 +528,10 @@ public class FileHandle extends SharedCloseableImpl
rebuffererFactory = maybeCached(new SimpleChunkReader(channel, length, bufferType, chunkSize));
}
}
Cleanup cleanup = new Cleanup(channel, rebuffererFactory, compressionMetadata, chunkCache);
FileHandle fileHandle = new FileHandle(cleanup, channel, rebuffererFactory, compressionMetadata, length);
return fileHandle;
Cleanup cleanup = new Cleanup(channel, rebuffererFactory, compressionMetadata, chunkCache);
return new FileHandle(cleanup, channel, rebuffererFactory, compressionMetadata, length, diskAccessMode, chunkCache,
mmappedRegionsCache, crcCheckChanceSupplier, lengthOverride, bufferSize, bufferType);
}
catch (Throwable t)
{

View File

@ -47,6 +47,7 @@ import java.util.stream.Stream;
import com.google.common.base.Preconditions;
import com.google.common.util.concurrent.RateLimiter;
import com.sun.nio.file.ExtendedOpenOption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -740,14 +741,54 @@ public final class FileUtils
}
}
public static boolean isDirectIOSupported(File file)
{
File testFile = null;
try
{
File dir = file.isDirectory() ? file : file.parent();
testFile = createTempFile("direct-io-test", ".tmp", dir);
// Direct IO requires knowing the block size for buffer alignment
if (blockSize(testFile) <= 0)
return false;
try (FileChannel channel = FileChannel.open(testFile.toPath(),
StandardOpenOption.READ,
ExtendedOpenOption.DIRECT))
{
return true;
}
}
catch (Exception e)
{
return false;
}
finally
{
if (testFile != null)
testFile.tryDelete();
}
}
public static int getFileBlockSize(File file)
{
try
{
return blockSize(file);
}
catch (IOException e)
{
throw new RuntimeException("Failed to get file block size in " + file, e);
}
}
public static int getBlockSize(File directory)
{
File f = FileUtils.createTempFile("block-size-test", ".tmp", directory);
try
{
long bs = Files.getFileStore(f.toPath()).getBlockSize();
assert bs >= 0 && bs <= Integer.MAX_VALUE;
return (int) bs;
return blockSize(f);
}
catch (IOException e)
{
@ -759,6 +800,13 @@ public final class FileUtils
}
}
private static int blockSize(File file) throws IOException
{
long bs = Files.getFileStore(file.toPath()).getBlockSize();
assert bs >= 0 && bs <= Integer.MAX_VALUE;
return (int) bs;
}
public static class DuplicateHardlinkException extends RuntimeException
{
public DuplicateHardlinkException(String message)

View File

@ -22,6 +22,7 @@ import java.nio.ByteOrder;
import javax.annotation.concurrent.NotThreadSafe;
import com.google.common.base.Preconditions;
import com.google.common.primitives.Ints;
import org.apache.cassandra.io.compress.BufferType;
@ -319,6 +320,31 @@ public class RandomAccessReader extends RebufferingInputStream implements FileDa
}
}
static class RandomAccessReaderWithOwnFile extends RandomAccessReader
{
private final FileHandle fileHandle;
RandomAccessReaderWithOwnFile(Rebufferer rebufferer, FileHandle fileHandle)
{
super(rebufferer);
this.fileHandle = Preconditions.checkNotNull(fileHandle, "fileHandle cannot be null");
}
@Override
public void close()
{
try
{
super.close();
}
finally
{
fileHandle.close();
}
}
}
/**
* Open a RandomAccessReader (not compressed, not mmapped, no read throttling) that will own its channel.
*

View File

@ -29,8 +29,9 @@ import io.netty.util.concurrent.FastThreadLocal;
/**
* Utility class that allow buffers to be reused by storing them in a thread local instance.
*/
public final class ThreadLocalByteBufferHolder
public final class ThreadLocalByteBufferHolder implements ByteBufferHolder
{
private static final EnumMap<BufferType, FastThreadLocal<ByteBuffer>> reusableBBHolder = new EnumMap<>(BufferType.class);
// Convenience variable holding a ref to the current resuableBB to avoid map lookups
private final FastThreadLocal<ByteBuffer> reusableBB;
@ -39,7 +40,7 @@ public final class ThreadLocalByteBufferHolder
{
for (BufferType bbType : BufferType.values())
{
reusableBBHolder.put(bbType, new FastThreadLocal<ByteBuffer>()
reusableBBHolder.put(bbType, new FastThreadLocal<>()
{
protected ByteBuffer initialValue()
{
@ -47,7 +48,7 @@ public final class ThreadLocalByteBufferHolder
}
});
}
};
}
/**
* The type of buffer that will be returned
@ -69,6 +70,7 @@ public final class ThreadLocalByteBufferHolder
* @param size the buffer size
* @return the buffer for the current thread.
*/
@Override
public ByteBuffer getBuffer(int size)
{
ByteBuffer buffer = reusableBB.get();

View File

@ -21,24 +21,27 @@ package org.apache.cassandra.io.util;
import java.nio.ByteBuffer;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import org.apache.cassandra.io.compress.BufferType;
import org.apache.cassandra.io.sstable.CorruptSSTableException;
import org.apache.cassandra.utils.Closeable;
import org.apache.cassandra.utils.memory.MemoryUtil;
import io.netty.util.concurrent.FastThreadLocal;
public final class ThreadLocalReadAheadBuffer
public class ThreadLocalReadAheadBuffer implements Closeable
{
private static class Block
{
ByteBuffer buffer = null;
int index = -1;
}
private final ChannelProxy channel;
protected final ChannelProxy channel;
private final BufferType bufferType;
private final Supplier<ByteBuffer> bufferSupplier;
private static final FastThreadLocal<Map<String, Block>> blockMap = new FastThreadLocal<>()
{
@ -49,15 +52,19 @@ public final class ThreadLocalReadAheadBuffer
}
};
private final int bufferSize;
private volatile int bufferSize = -1;
private final long channelSize;
public ThreadLocalReadAheadBuffer(ChannelProxy channel, int bufferSize, BufferType bufferType)
{
this(channel, () -> bufferType.allocate(bufferSize));
}
public ThreadLocalReadAheadBuffer(ChannelProxy channel, Supplier<ByteBuffer> bufferSupplier)
{
this.channel = channel;
this.channelSize = channel.size();
this.bufferSize = bufferSize;
this.bufferType = bufferType;
this.bufferSupplier = bufferSupplier;
}
public boolean hasBuffer()
@ -65,9 +72,6 @@ public final class ThreadLocalReadAheadBuffer
return block().buffer != null;
}
/**
* Safe to call only if {@link #hasBuffer()} is true
*/
public int remaining()
{
return getBlock().buffer.remaining();
@ -83,8 +87,10 @@ public final class ThreadLocalReadAheadBuffer
Block block = block();
if (block.buffer == null)
{
block.buffer = bufferType.allocate(bufferSize);
block.buffer = bufferSupplier.get();
block.buffer.clear();
if (bufferSize == -1)
bufferSize = block.buffer.capacity();
}
return block;
}
@ -107,10 +113,7 @@ public final class ThreadLocalReadAheadBuffer
if (block.index != blockNo)
{
blockBuffer.flip();
blockBuffer.limit(sizeToRead);
if (channel.read(blockBuffer, blockPosition) != sizeToRead)
throw new CorruptSSTableException(null, channel.filePath());
loadBlock(blockBuffer, blockPosition, sizeToRead);
block.index = blockNo;
}
@ -119,6 +122,13 @@ public final class ThreadLocalReadAheadBuffer
blockBuffer.position((int) (realPosition - blockPosition));
}
protected void loadBlock(ByteBuffer blockBuffer, long blockPosition, int sizeToRead)
{
blockBuffer.limit(sizeToRead);
if (channel.read(blockBuffer, blockPosition) != sizeToRead)
throw new CorruptSSTableException(null, channel.filePath());
}
public int read(ByteBuffer dest, int length)
{
Block block = getBlock();
@ -151,6 +161,7 @@ public final class ThreadLocalReadAheadBuffer
}
}
@Override
public void close()
{
clear(true);

View File

@ -126,6 +126,7 @@ public class StartupChecks
checkMaxMapCount,
checkReadAheadKbSetting,
checkDataDirs,
checkDirectIOSupport,
checkSSTablesFormat,
checkSystemKeyspaceState,
checkLegacyAuthTables,
@ -258,7 +259,8 @@ public class StartupChecks
Set<Path> directIOWritePaths = new HashSet<>();
if (DatabaseDescriptor.getCommitLogWriteDiskAccessMode() == Config.DiskAccessMode.direct)
directIOWritePaths.add(new File(DatabaseDescriptor.getCommitLogLocation()).toPath());
// TODO: add data directories when direct IO is supported for flushing and compaction
// Note: Data directories for direct IO compaction reads are checked in checkDirectIOSupport.
// This check is specifically for direct IO writes which are currently only supported for commit log.
if (!directIOWritePaths.isEmpty() && IGNORE_KERNEL_BUG_1057843_CHECK.getBoolean())
{
@ -718,6 +720,57 @@ public class StartupChecks
}
};
public static final StartupCheck checkDirectIOSupport = new StartupCheck()
{
@Override
public String name()
{
return "directio_support";
}
@Override
public void execute(StartupChecksConfiguration configuration) throws StartupException
{
if (configuration.isDisabled(name()))
return;
// Only check if compaction_read_disk_access_mode is direct
if (DatabaseDescriptor.getCompactionReadDiskAccessMode() != Config.DiskAccessMode.direct)
return;
List<String> unsupportedLocations = findDirectIOUnsupportedLocations(DatabaseDescriptor.getAllDataFileLocations());
if (!unsupportedLocations.isEmpty())
{
throw new StartupException(StartupException.ERR_WRONG_DISK_STATE,
String.format("Direct I/O is configured for compaction reads (compaction_read_disk_access_mode=direct), " +
"but the following data directories do not support Direct I/O: %s. " +
"Either change compaction_read_disk_access_mode to 'standard' in cassandra.yaml, " +
"or ensure all data directories are on filesystems that support Direct I/O. " +
"Network filesystems (NFS, CIFS) and some virtual filesystems do not support Direct I/O.",
unsupportedLocations));
}
}
};
@VisibleForTesting
static List<String> findDirectIOUnsupportedLocations(String[] dataFileLocations)
{
List<String> unsupportedLocations = new ArrayList<>();
for (String dataDir : dataFileLocations)
{
File dir = new File(dataDir);
if (!dir.exists())
continue; // Directory doesn't exist yet, skip
if (!FileUtils.isDirectIOSupported(dir))
unsupportedLocations.add(dataDir);
}
return unsupportedLocations;
}
public static final StartupCheck checkSSTablesFormat = new StartupCheck()
{
@Override

View File

@ -48,6 +48,7 @@ import org.junit.runners.Parameterized;
import org.junit.runners.Parameterized.Parameters;
import org.apache.cassandra.Util;
import org.apache.cassandra.config.Config.DiskAccessMode;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.DataRange;
import org.apache.cassandra.db.Keyspace;
@ -301,6 +302,16 @@ public class FailingRepairTest extends TestBaseImpl implements Serializable
return new FailingISSTableScanner();
}
public ISSTableScanner getScanner(DiskAccessMode diskAccessMode)
{
return new FailingISSTableScanner();
}
public ISSTableScanner getScanner(Collection<Range<Token>> ranges, DiskAccessMode diskAccessMode)
{
return new FailingISSTableScanner();
}
public ChannelProxy getDataChannel()
{
throw new RuntimeException();

View File

@ -27,6 +27,7 @@ import java.util.Set;
import com.google.common.util.concurrent.RateLimiter;
import org.apache.cassandra.config.Config.DiskAccessMode;
import org.apache.cassandra.db.ColumnFamilyStore;
import org.apache.cassandra.db.DataRange;
import org.apache.cassandra.db.DecoratedKey;
@ -344,6 +345,18 @@ public abstract class ForwardingSSTableReader extends SSTableReader
return delegate.getScanner(rangeIterator);
}
@Override
public ISSTableScanner getScanner(DiskAccessMode diskAccessMode)
{
return delegate.getScanner(diskAccessMode);
}
@Override
public ISSTableScanner getScanner(Collection<Range<Token>> ranges, DiskAccessMode diskAccessMode)
{
return delegate.getScanner(ranges, diskAccessMode);
}
@Override
public UnfilteredPartitionIterator partitionIterator(ColumnFilter columns, DataRange dataRange, SSTableReadsListener listener)
{

View File

@ -53,7 +53,7 @@ public class DirectIOSegmentBytemanTest
@Test
@BMRules(rules = { @BMRule(name = "Commitlog dir do not support direct io",
targetClass = "FileUtils",
targetMethod = "getBlockSize",
targetMethod = "blockSize",
action = "return 0;") } )
public void testDirectIOUnSupportWithDirectConfig()
{

View File

@ -398,7 +398,7 @@ public class CheckpointIntervalArrayIndexTest
Map<IndexComponent, FileHandle> files = new EnumMap<>(IndexComponent.class);
for (IndexComponent c : descriptor.getLiveComponents())
files.put(c, new FileHandle.Builder(descriptor.fileFor(c)).mmapped(true).complete());
files.put(c, new FileHandle.Builder(descriptor.fileFor(c)).mmapped().complete());
List<Segment> segments = RouteIndexFormat.readSegments(files);
files.remove(IndexComponent.SEGMENT).close();
files.remove(IndexComponent.METADATA).close();

View File

@ -44,6 +44,7 @@ import org.apache.cassandra.io.util.SequentialWriterOption;
import org.apache.cassandra.schema.CompressionParams;
import org.apache.cassandra.utils.SyncUtil;
import static org.apache.cassandra.config.Config.DiskAccessMode;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@ -62,40 +63,40 @@ public class CompressedRandomAccessReaderTest
public void testResetAndTruncate() throws IOException
{
// test reset in current buffer or previous one
testResetAndTruncate(FileUtils.createTempFile("normal", "1"), false, false, 10, 0);
testResetAndTruncate(FileUtils.createTempFile("normal", "2"), false, false, CompressionParams.DEFAULT_CHUNK_LENGTH, 0);
testResetAndTruncate(FileUtils.createTempFile("normal", "1"), false, DiskAccessMode.standard, 10);
testResetAndTruncate(FileUtils.createTempFile("normal", "2"), false, DiskAccessMode.standard, CompressionParams.DEFAULT_CHUNK_LENGTH);
}
@Test
public void testResetAndTruncateCompressed() throws IOException
{
// test reset in current buffer or previous one
testResetAndTruncate(FileUtils.createTempFile("compressed", "1"), true, false, 10, 0);
testResetAndTruncate(FileUtils.createTempFile("compressed", "2"), true, false, CompressionParams.DEFAULT_CHUNK_LENGTH, 0);
testResetAndTruncate(FileUtils.createTempFile("compressed", "1"), true, DiskAccessMode.standard, 10);
testResetAndTruncate(FileUtils.createTempFile("compressed", "2"), true, DiskAccessMode.standard, CompressionParams.DEFAULT_CHUNK_LENGTH);
}
@Test
public void testResetAndTruncateCompressedMmap() throws IOException
{
// test reset in current buffer or previous one
testResetAndTruncate(FileUtils.createTempFile("compressed_mmap", "1"), true, true, 10, 0);
testResetAndTruncate(FileUtils.createTempFile("compressed_mmap", "2"), true, true, CompressionParams.DEFAULT_CHUNK_LENGTH, 0);
testResetAndTruncate(FileUtils.createTempFile("compressed_mmap", "1"), true, DiskAccessMode.mmap, 10);
testResetAndTruncate(FileUtils.createTempFile("compressed_mmap", "2"), true, DiskAccessMode.mmap, CompressionParams.DEFAULT_CHUNK_LENGTH);
}
@Test
public void testResetAndTruncateCompressedUncompressedChunks() throws IOException
{
// test reset in current buffer or previous one
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks", "1"), true, false, 10, 3);
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks", "2"), true, false, CompressionParams.DEFAULT_CHUNK_LENGTH, 3);
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks", "1"), true, DiskAccessMode.standard, 10);
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks", "2"), true, DiskAccessMode.standard, CompressionParams.DEFAULT_CHUNK_LENGTH);
}
@Test
public void testResetAndTruncateCompressedUncompressedChunksMmap() throws IOException
{
// test reset in current buffer or previous one
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks_mmap", "1"), true, true, 10, 3);
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks_mmap", "2"), true, true, CompressionParams.DEFAULT_CHUNK_LENGTH, 3);
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks_mmap", "1"), true, DiskAccessMode.mmap, 10);
testResetAndTruncate(FileUtils.createTempFile("compressed_uchunks_mmap", "2"), true, DiskAccessMode.mmap, CompressionParams.DEFAULT_CHUNK_LENGTH);
}
@Test
@ -177,13 +178,16 @@ public class CompressedRandomAccessReaderTest
}
}
private static void testResetAndTruncate(File f, boolean compressed, boolean usemmap, int junkSize, double minCompressRatio) throws IOException
private static void testResetAndTruncate(File f, boolean compressed, DiskAccessMode diskAccessMode, int junkSize) throws IOException
{
final String filename = f.absolutePath();
writeSSTable(f, compressed ? CompressionParams.snappy() : null, junkSize);
try (CompressionMetadata compressionMetadata = compressed ? CompressionMetadata.open(new File(filename + ".metadata"), f.length(), true) : null;
FileHandle fh = new FileHandle.Builder(f).mmapped(usemmap).withCompressionMetadata(compressionMetadata).complete();
FileHandle fh = new FileHandle.Builder(f)
.withCompressionMetadata(compressionMetadata)
.withDiskAccessMode(diskAccessMode)
.complete();
RandomAccessReader reader = fh.createReader())
{
String expected = "The quick brown fox jumps over the lazy dog";

View File

@ -101,7 +101,7 @@ public class PartitionIndexTest
}
@Parameterized.Parameter(value = 0)
public static Config.DiskAccessMode accessMode = Config.DiskAccessMode.standard;
public static Config.DiskAccessMode diskAccessMode = Config.DiskAccessMode.standard;
@BeforeClass
public static void beforeClass()
@ -878,7 +878,7 @@ public class PartitionIndexTest
{
return new FileHandle.Builder(file)
.bufferSize(PageAware.PAGE_SIZE)
.mmapped(accessMode == Config.DiskAccessMode.mmap)
.withDiskAccessMode(diskAccessMode)
.withChunkCache(ChunkCache.instance);
}

View File

@ -93,7 +93,7 @@ public class RowIndexTest
}
@Parameterized.Parameter(value = 0)
public static Config.DiskAccessMode accessMode = Config.DiskAccessMode.standard;
public static Config.DiskAccessMode diskAccessMode = Config.DiskAccessMode.standard;
@Test
public void testSingletons() throws IOException
@ -201,7 +201,7 @@ public class RowIndexTest
{
complete();
FileHandle.Builder builder = new FileHandle.Builder(file).mmapped(accessMode == Config.DiskAccessMode.mmap);
FileHandle.Builder builder = new FileHandle.Builder(file).withDiskAccessMode(diskAccessMode);
fh = builder.complete();
try (RandomAccessReader rdr = fh.createReader())
{

View File

@ -0,0 +1,61 @@
/*
* 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.cassandra.io.util;
import accord.utils.Gen;
import accord.utils.Gens;
import org.apache.cassandra.schema.CompressionParams;
public abstract class CompressedChunkReaderTestBase
{
static Gen<SequentialWriterOption> writerOptions()
{
Gen<Integer> bufferSizes = Gens.constant(1 << 10);
return rs -> writerOption(bufferSizes.next(rs));
}
static SequentialWriterOption writerOption(int bufferSize)
{
return SequentialWriterOption.newBuilder()
.finishOnClose(false)
.bufferSize(bufferSize)
.build();
}
private enum CompressionKind { Noop, Snappy, Deflate, Lz4, Zstd }
static Gen<CompressionParams> compressionParams(Gen<Integer> chunkLengths)
{
Gen<Double> compressionRatio = Gens.pick(1.1D);
return rs -> {
CompressionKind kind = rs.pick(CompressionKind.values());
switch (kind)
{
case Noop: return CompressionParams.noop();
case Snappy: return CompressionParams.snappy(chunkLengths.next(rs), compressionRatio.next(rs));
case Deflate: return CompressionParams.deflate(chunkLengths.next(rs));
case Lz4: return CompressionParams.lz4(chunkLengths.next(rs));
case Zstd: return CompressionParams.zstd(chunkLengths.next(rs));
default: throw new UnsupportedOperationException(kind.name());
}
};
}
}

View File

@ -0,0 +1,545 @@
/*
* 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.cassandra.io.util;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.channels.ReadableByteChannel;
import java.nio.channels.WritableByteChannel;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.Assert;
import org.junit.BeforeClass;
import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import accord.utils.Gen;
import accord.utils.Gens;
import accord.utils.RandomSource;
import org.apache.cassandra.config.DatabaseDescriptor;
import org.apache.cassandra.db.ClusteringComparator;
import org.apache.cassandra.io.compress.CompressedSequentialWriter;
import org.apache.cassandra.io.compress.CompressionMetadata;
import org.apache.cassandra.io.sstable.CorruptSSTableException;
import org.apache.cassandra.io.sstable.metadata.MetadataCollector;
import org.apache.cassandra.schema.CompressionParams;
import org.apache.cassandra.utils.memory.MemoryUtil;
import static accord.utils.Property.qt;
import static org.apache.cassandra.config.CassandraRelevantProperties.JAVA_IO_TMPDIR;
import static org.apache.cassandra.schema.CompressionParams.DEFAULT_CHUNK_LENGTH;
public class DirectCompressedChunkReaderTest extends CompressedChunkReaderTestBase
{
private static final int CHECKSUM_LENGTH = Integer.BYTES; // CRC32 checksum size
private static final Logger logger = LoggerFactory.getLogger(DirectCompressedChunkReaderTest.class);
private static int seed;
static
{
DatabaseDescriptor.clientInitialization();
}
@BeforeClass
public static void setup()
{
seed = new Random().nextInt();
logger.info("Seed: {}", seed);
}
private static Gen<Integer> mixedChunkLengths()
{
int minLength = 1024;
int maxLength = 1024 * 64;
return Gens.pick(Stream.iterate(minLength, n -> n <= maxLength, n -> n * 2)
.collect(Collectors.toList()));
}
@Test
public void compressedReads()
{
var optionGen = writerOptions();
var paramsGen = compressionParams(mixedChunkLengths());
var fileLengthGen = Gens.longs().between(1, 1 << 16);
testReads(optionGen, paramsGen, fileLengthGen, false);
}
@Test
public void scanCompressedReads()
{
var optionGen = writerOptions();
var paramsGen = compressionParams(mixedChunkLengths());
var fileLengthGen = Gens.longs().between(1, 1 << 16);
testReads(optionGen, paramsGen, fileLengthGen, true);
}
@Test
public void compressedReads_edgeCases()
{
var optionGen = writerOptions();
int chunkLength = 4096;
var paramsGen = compressionParams(Gens.constant(chunkLength));
// Test file smaller than chunk length
testReads(optionGen, paramsGen, Gens.longs().of(1024), true);
// Test partial trailing chunk
testReads(optionGen, paramsGen, Gens.longs().of(chunkLength + 96), true);
}
@Test
public void corruptBlock()
{
Gen<Integer> chunkLength = Gens.constant(4096);
Gen<CompressionParams> compressionGen = compressionParams(chunkLength);
qt().withSeed(seed).forAll(Gens.random(), compressionGen).check((rs, params) -> {
long totalBytesToWrite = params.chunkLength() * 2L; // two chunks
List<ByteBuffer> chunks = generateRandomChunks(rs, params, totalBytesToWrite);
File dataFile = new File(JAVA_IO_TMPDIR.getString() + "data_corrupt_block.bin");
final CompressionMetadata metadata;
try (CompressedSequentialWriter writer = getCompressedSequentialWriter(writerOption(1 << 10), params, dataFile))
{
for (ByteBuffer chunk : chunks)
writer.write(chunk.duplicate());
writer.sync();
metadata = writer.open(0);
}
CompressionMetadata.Chunk firstChunkMeta = metadata.chunkFor(0);
long truncatePoint = firstChunkMeta.offset + (long) firstChunkMeta.length / 2; // Halfway into the chunk
try (FileChannel fileChannel = FileChannel.open(dataFile.toPath(), StandardOpenOption.WRITE))
{
fileChannel.truncate(truncatePoint);
}
boolean exceptionThrown = false;
try
{
readAndVerifyChunks(dataFile, metadata, chunks, totalBytesToWrite, false);
}
catch (CorruptSSTableException exc)
{
exceptionThrown = true;
Assert.assertTrue("Exception message should indicate corrupt SSTable", exc.getMessage().contains(dataFile.name()));
}
finally
{
metadata.close();
Files.deleteIfExists(dataFile.toPath());
}
Assert.assertTrue("Expected CorruptSSTableException for truncated chunk, but none was thrown.", exceptionThrown);
});
}
@Test
public void uncompressedReads()
{
testUncompressedReads(false);
}
@Test
public void scanUncompressedReads()
{
testUncompressedReads(true);
}
private void testUncompressedReads(boolean forScan)
{
int maxCompressedLength = 0; // force uncompressed path (chunk.length > maxCompressedLength)
CompressionParams uncompressed = CompressionParams.lz4(DEFAULT_CHUNK_LENGTH, maxCompressedLength);
var optionGen = writerOptions();
var paramsGen = Gens.constant(uncompressed);
var fileLengthGen = Gens.longs().between(1, 1 << 16);
testReads(optionGen, paramsGen, fileLengthGen, forScan);
}
@Test
public void largeFilePositionReads() throws Exception
{
long FOUR_GB = 1L << 32;
int chunkLength = DEFAULT_CHUNK_LENGTH;
// Test critical boundaries for compressed file offsets
long[] testOffsets = {
FOUR_GB - 1024, // Just below 4GB
FOUR_GB, // Exactly 4GB
FOUR_GB + 1024, // Just above 4GB
FOUR_GB + (1L << 30), // 5GB (4GB + 1GB)
FOUR_GB * 2 // 8GB
};
CompressionParams params = CompressionParams.lz4(chunkLength); // Production default compressor
for (long offset : testOffsets)
testReadAtLargeOffset(offset, chunkLength, params);
}
private static void testReads(Gen<SequentialWriterOption> optionGen, Gen<CompressionParams> paramsGen, Gen.LongGen totalBytesGen, boolean forScan)
{
qt().withSeed(seed).forAll(Gens.random(), optionGen, paramsGen).check((rs, option, params) -> {
long totalBytesToWrite = totalBytesGen.nextLong(rs);
List<ByteBuffer> chunks = generateRandomChunks(rs, params, totalBytesToWrite);
File file = new File(JAVA_IO_TMPDIR.getString() + "data.bin");
try (CompressedSequentialWriter writer = getCompressedSequentialWriter(option, params, file))
{
for (ByteBuffer chunk : chunks)
writer.write(chunk.duplicate());
writer.sync();
CompressionMetadata metadata = writer.open(0);
readAndVerifyChunks(file, metadata, chunks, totalBytesToWrite, forScan);
}
finally
{
Files.deleteIfExists(file.toPath());
}
});
}
private static void readAndVerifyChunks(File file, CompressionMetadata metadata, List<ByteBuffer> expectedChunksData, long totalBytesExpected,
boolean forScan)
{
ByteBuffer readBuffer = ByteBuffer.allocateDirect(metadata.chunkLength());
try (ChannelProxy channel = new ChannelProxy(file, ChannelProxy.IOMode.DIRECT);
CompressedChunkReader reader = new CompressedChunkReader.Direct(channel, metadata, () -> 1d);
metadata)
{
if (forScan)
reader.forScan();
long currentFileOffset = 0;
long totalBytesRead = 0;
int currentChunkIndex = 0;
while (totalBytesRead < totalBytesExpected)
{
ByteBuffer currentExpectedChunk = expectedChunksData.get(currentChunkIndex);
readBuffer.clear();
reader.readChunk(currentFileOffset, readBuffer);
Assert.assertTrue("Read buffer is empty unexpectedly at offset " + currentFileOffset, readBuffer.hasRemaining());
int actualBytesRead = readBuffer.remaining();
int expectedBytes = currentExpectedChunk.remaining();
Assert.assertTrue("Read buffer remaining (" + actualBytesRead + ") is less than expected (" + expectedBytes + ") at offset " + currentFileOffset,
actualBytesRead >= expectedBytes);
int originalReadBufferLimit = readBuffer.limit();
readBuffer.limit(expectedBytes);
Assert.assertEquals("Mismatched data at offset " + currentFileOffset, currentExpectedChunk, readBuffer);
readBuffer.limit(originalReadBufferLimit);
totalBytesRead += expectedBytes;
currentFileOffset += metadata.chunkLength();
currentChunkIndex++;
}
}
finally
{
MemoryUtil.clean(readBuffer);
}
}
private static List<ByteBuffer> generateRandomChunks(RandomSource rs, CompressionParams params, long bytesToWrite)
{
List<ByteBuffer> chunks = new ArrayList<>();
long bytesGenerated = 0;
while (bytesGenerated < bytesToWrite)
{
ByteBuffer chunkBuffer = ByteBuffer.allocate(params.chunkLength());
int bytesToFill = (int) Math.min(chunkBuffer.capacity(), bytesToWrite - bytesGenerated);
byte[] tempBytes = new byte[bytesToFill];
rs.nextBytes(tempBytes);
chunkBuffer.put(tempBytes);
chunkBuffer.flip();
chunks.add(chunkBuffer);
bytesGenerated += bytesToFill;
}
return chunks;
}
private static CompressedSequentialWriter getCompressedSequentialWriter(SequentialWriterOption option, CompressionParams params, File dataFile)
{
return new CompressedSequentialWriter(dataFile, new File("file.offset"), new File("file.digest"), option, params, new MetadataCollector(new ClusteringComparator()));
}
/**
* Test reading from a large file offset to verify no integer overflow.
* Creates a simulated compressed file with a chunk at a very large offset (>4GB)
*/
private static void testReadAtLargeOffset(long chunkOffset, int chunkLength, CompressionParams params) throws Exception
{
byte[] uncompressedData = new byte[chunkLength];
for (int i = 0; i < chunkLength; i++)
uncompressedData[i] = (byte) (i % 256); // Repeating pattern for realistic compression
ByteBuffer uncompressedBuf = ByteBuffer.wrap(uncompressedData);
// Compress the data
ByteBuffer compressedBuf = ByteBuffer.allocate(chunkLength * 2);
params.getSstableCompressor().compress(uncompressedBuf, compressedBuf);
compressedBuf.flip();
byte[] compressedData = new byte[compressedBuf.remaining()];
compressedBuf.get(compressedData);
// Create a fake channel that simulates a compressed file with data at a large offset
FakeLargeFileChannel fakeChannel = new FakeLargeFileChannel(compressedData, chunkOffset);
// Create a temp file for ChannelProxy (the file itself isn't used, only the fake channel)
File tmpFile = FileUtils.createTempFile("large_file_test", ".bin");
try
{
// Create metadata indicating uncompressed position 0 maps to compressed offset at chunkOffset
try (ChannelProxy channelProxy = new ChannelProxy(tmpFile, fakeChannel); CompressionMetadata metadata = createLargeFileMetadata(0, chunkLength, chunkOffset, compressedData.length, params); CompressedChunkReader directReader = new CompressedChunkReader.Direct(channelProxy, metadata, () -> 0d))
{
// Read uncompressed position 0, which will read from the large compressed file offset
ByteBuffer readBuffer = ByteBuffer.allocateDirect(chunkLength);
try
{
directReader.readChunk(0, readBuffer);
// Verify we got a full chunk back
Assert.assertTrue("Read buffer should have data for chunk at offset " + chunkOffset, readBuffer.hasRemaining());
Assert.assertEquals("Read buffer should contain full chunk", chunkLength, readBuffer.remaining());
// Verify decompressed data matches original
byte[] readData = new byte[chunkLength];
readBuffer.get(readData);
Assert.assertArrayEquals("Data mismatch for chunk at offset " + chunkOffset,
uncompressedData,
readData);
}
finally
{
MemoryUtil.clean(readBuffer);
}
}
}
finally
{
Files.deleteIfExists(tmpFile.toPath());
}
}
private static CompressionMetadata createLargeFileMetadata(long chunkIndex, int chunkLength, long chunkOffset,
int compressedLength, CompressionParams params)
{
// Allocate space for chunk offset index: need current offset + next offset to calculate length
Memory chunkOffsets = Memory.allocate(2 * Long.BYTES);
// Chunk offset index stores where each compressed chunk starts in the file
chunkOffsets.setLong(0, chunkOffset); // This chunk's compressed offset
chunkOffsets.setLong(8, chunkOffset + compressedLength + CHECKSUM_LENGTH); // Next chunk offset (current + data + checksum)
long dataLength = (chunkIndex + 1) * chunkLength; // Total uncompressed data (one chunk = 16KB)
long compressedFileLength = chunkOffset + compressedLength + CHECKSUM_LENGTH; // Total compressed file size
File chunksIndexFile = new File(JAVA_IO_TMPDIR.getString() + "large_file_chunks.index");
return new CompressionMetadata(chunksIndexFile,
params,
chunkOffsets,
chunkOffsets.size(),
dataLength,
compressedFileLength,
null);
}
/**
* Fake FileChannel that simulates a large compressed file (>4GB) without requiring actual disk space.
* This allows testing integer overflow scenarios by:
* - Reporting a large file size (e.g., 8GB) via size()
* - Storing only the actual compressed chunk data in memory (a few KB)
* - Mapping reads at high offsets (>4GB) to the in-memory data
* - Returning zeros for reads outside the compressed data region
* This approach enables efficient testing of large file position handling without creating multi-GB test files.
*/
private static class FakeLargeFileChannel extends FileChannel
{
private final byte[] compressedData;
private final long chunkOffset;
private final long fileSize;
private long position;
FakeLargeFileChannel(byte[] compressedData, long chunkOffset)
{
this.compressedData = compressedData;
this.chunkOffset = chunkOffset;
// File size = offset to chunk + compressed chunk data + CRC32 checksum
// This represents the minimum file size needed to contain the chunk at the specified offset
this.fileSize = chunkOffset + compressedData.length + CHECKSUM_LENGTH;
}
@Override
public int read(ByteBuffer dst, long readPosition)
{
int bytesToRead = dst.remaining();
// Simulate reading from file: return actual data at chunk offset, zeros elsewhere
for (int i = 0; i < bytesToRead; i++)
{
long fileOffset = readPosition + i;
if (fileOffset >= chunkOffset && fileOffset < chunkOffset + compressedData.length)
{
// Reading from compressed data region
int dataOffset = (int) (fileOffset - chunkOffset);
dst.put(compressedData[dataOffset]);
}
else if (fileOffset >= chunkOffset + compressedData.length && fileOffset < fileSize)
{
// Reading from checksum region (4 bytes after compressed data)
dst.put((byte) 0); // Dummy checksum
}
else
{
// Reading outside our chunk - return padding
dst.put((byte) 0);
}
}
return bytesToRead;
}
@Override
public int read(ByteBuffer dst)
{
int read = read(dst, position);
position += read;
return read;
}
@Override
public long size()
{
return fileSize;
}
@Override
public long position()
{
return position;
}
@Override
public FileChannel position(long newPosition)
{
this.position = newPosition;
return this;
}
// Unsupported operations
@Override
public long read(ByteBuffer[] dsts, int offset, int length)
{
throw new UnsupportedOperationException();
}
@Override
public int write(ByteBuffer src)
{
throw new UnsupportedOperationException();
}
@Override
public int write(ByteBuffer src, long position)
{
throw new UnsupportedOperationException();
}
@Override
public long write(ByteBuffer[] srcs, int offset, int length)
{
throw new UnsupportedOperationException();
}
@Override
public FileChannel truncate(long size)
{
throw new UnsupportedOperationException();
}
@Override
public void force(boolean metaData)
{
}
@Override
public long transferTo(long position, long count, WritableByteChannel target)
{
throw new UnsupportedOperationException();
}
@Override
public long transferFrom(ReadableByteChannel src, long position, long count)
{
throw new UnsupportedOperationException();
}
@Override
public MappedByteBuffer map(FileChannel.MapMode mode, long position, long size)
{
throw new UnsupportedOperationException();
}
@Override
public FileLock lock(long position, long size, boolean shared)
{
throw new UnsupportedOperationException();
}
@Override
public FileLock tryLock(long position, long size, boolean shared)
{
throw new UnsupportedOperationException();
}
@Override
protected void implCloseChannel()
{
}
}
}

View File

@ -0,0 +1,101 @@
/*
* 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.cassandra.io.util;
import java.nio.ByteBuffer;
import org.junit.Assert;
import org.junit.Test;
public class DirectThreadLocalByteBufferHolderTest
{
@Test
public void testGetBuffer()
{
int blockSize = 4096;
int bufferSize = blockSize * 4;
DirectThreadLocalByteBufferHolder holder = new DirectThreadLocalByteBufferHolder(blockSize);
// Initial buffer creation
ByteBuffer buffer = holder.getBuffer(bufferSize);
Assert.assertEquals(bufferSize, buffer.limit());
Assert.assertEquals(0, buffer.position());
buffer.put(new byte[bufferSize]);
// Reuse same buffer
ByteBuffer sameBuffer = holder.getBuffer(bufferSize);
Assert.assertSame(buffer, sameBuffer);
Assert.assertEquals(0, sameBuffer.position());
}
@Test
public void testBufferGrows()
{
int blockSize = 4096;
DirectThreadLocalByteBufferHolder holder = new DirectThreadLocalByteBufferHolder(blockSize);
ByteBuffer small = holder.getBuffer(blockSize);
ByteBuffer large = holder.getBuffer(blockSize * 4);
Assert.assertNotSame(small, large);
Assert.assertEquals(blockSize * 4, large.capacity());
}
@Test
public void testAlignmentRoundsUp()
{
int blockSize = 4096;
DirectThreadLocalByteBufferHolder holder = new DirectThreadLocalByteBufferHolder(blockSize);
// Request non-aligned size
ByteBuffer buffer = holder.getBuffer(blockSize + 100);
// Should be rounded up to next block boundary
Assert.assertEquals(blockSize * 2, buffer.capacity());
Assert.assertEquals(blockSize * 2, buffer.limit());
}
@Test
public void testSharedAcrossHolders()
{
int blockSize = 4096;
DirectThreadLocalByteBufferHolder holder1 = new DirectThreadLocalByteBufferHolder(blockSize);
ByteBuffer buffer1 = holder1.getBuffer(blockSize);
DirectThreadLocalByteBufferHolder holder2 = new DirectThreadLocalByteBufferHolder(blockSize);
ByteBuffer buffer2 = holder2.getBuffer(blockSize);
// Same block size = same underlying thread-local = same buffer
Assert.assertSame(buffer1, buffer2);
}
@Test
public void testDifferentBlockSizesAreSeparate()
{
DirectThreadLocalByteBufferHolder holder4k = new DirectThreadLocalByteBufferHolder(4096);
DirectThreadLocalByteBufferHolder holder8k = new DirectThreadLocalByteBufferHolder(8192);
ByteBuffer buffer4k = holder4k.getBuffer(4096);
ByteBuffer buffer8k = holder8k.getBuffer(8192);
Assert.assertNotSame(buffer4k, buffer8k);
}
}

View File

@ -0,0 +1,64 @@
/*
* 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.cassandra.io.util;
import java.util.Arrays;
import org.junit.Test;
import org.apache.cassandra.utils.Pair;
public class DirectThreadLocalReadAheadBufferTest extends ThreadLocalReadAheadBufferTest
{
@Test
public void testBufferSizes()
{
int blockSize = FileUtils.getFileBlockSize(files[0]);
for (Integer bufferSize : Arrays.asList(blockSize, // block equal alignment
blockSize / 2, // sub-block alignment
blockSize + (blockSize / 2), // straddling alignment
blockSize * 16)) // multi-block alignment
{
qt().withFixedSeed(seed).forAll(reads())
.checkAssert(propertyInputs -> testReads(propertyInputs, bufferSize, blockSize));
}
}
@Override
protected void testReads(InputData propertyInputs)
{
int blockSize = FileUtils.getFileBlockSize(propertyInputs.file);
testReads(propertyInputs, blockSize * 64, blockSize);
}
private void testReads(InputData propertyInputs, int bufferSize, int blockSize)
{
try (ChannelProxy bufferedChannel = new ChannelProxy(propertyInputs.file);
ChannelProxy directChannel = new ChannelProxy(propertyInputs.file, ChannelProxy.IOMode.DIRECT))
{
ThreadLocalReadAheadBuffer tlrab = new DirectThreadLocalReadAheadBuffer(directChannel, bufferSize, blockSize);
for (Pair<Long, Integer> read : propertyInputs.positionsAndLengths)
{
testRead(read, bufferedChannel, tlrab);
}
tlrab.close();
}
}
}

View File

@ -293,7 +293,9 @@ public class RandomAccessReaderTest
final File f = writeFile(params);
FileHandle.Builder builder = new FileHandle.Builder(f).bufferType(params.bufferType)
.bufferSize(params.bufferSize);
builder.mmapped(params.mmappedRegions);
if (params.mmappedRegions)
builder.mmapped();
try (FileHandle fh = builder.complete();
RandomAccessReader reader = fh.createReader())
{
@ -510,21 +512,6 @@ public class RandomAccessReaderTest
testSkipBytes(params, numberOfExpectationsInBufferSize + 1);
}
public void testSkipBytesNonPositive() throws IOException
{
Parameters params = new Parameters(8192, 4096);
final File f = writeFile(params);
FileHandle.Builder builder = new FileHandle.Builder(f).bufferType(params.bufferType)
.bufferSize(params.bufferSize)
.mmapped(params.mmappedRegions);
try (FileHandle fh = builder.complete();
RandomAccessReader reader = fh.createReader())
{
assertEquals(0, reader.skipBytes(0));
assertEquals(0, reader.skipBytes(-1));
}
}
@Test(expected = IOException.class)
public void testSkipBytesClosed() throws IOException
{
@ -544,8 +531,11 @@ public class RandomAccessReaderTest
{
final File f = writeFile(params);
FileHandle.Builder builder = new FileHandle.Builder(f).bufferType(params.bufferType)
.bufferSize(params.bufferSize)
.mmapped(params.mmappedRegions);
.bufferSize(params.bufferSize);
if (params.mmappedRegions)
builder.mmapped();
try (FileHandle fh = builder.complete();
RandomAccessReader reader = fh.createReader())
{

View File

@ -26,7 +26,6 @@ import org.assertj.core.api.Assertions;
import org.junit.Assert;
import org.junit.Test;
import accord.utils.Gen;
import accord.utils.Gens;
import org.apache.cassandra.config.DatabaseDescriptor;
@ -40,7 +39,7 @@ import org.apache.cassandra.utils.memory.MemoryUtil;
import static accord.utils.Property.qt;
public class CompressedChunkReaderTest
public class StandardCompressedChunkReaderTest extends CompressedChunkReaderTestBase
{
static
{
@ -50,17 +49,16 @@ public class CompressedChunkReaderTest
@Test
public void scanReaderReadsLessThanRAReader()
{
var optionGen = options();
var paramsGen = params();
var optionGen = writerOptions();
var paramsGen = compressionParams(Gens.constant(CompressionParams.DEFAULT_CHUNK_LENGTH));
var lengthGen = Gens.longs().between(1, 1 << 16);
qt().withSeed(-1871070464864118891L).forAll(Gens.random(), optionGen, paramsGen).check((rs, option, params) -> {
qt().forAll(Gens.random(), optionGen, paramsGen).check((rs, option, params) -> {
ListenableFileSystem fs = FileSystems.newGlobalInMemoryFileSystem();
File f = new File("/file.db");
File f = new File("/file.bin");
AtomicInteger reads = new AtomicInteger();
fs.onPostRead(f.path::equals, (p, c, pos, dst, r) -> {
reads.incrementAndGet();
});
fs.onPostRead(f.path::equals, (p, c, pos, dst, r) -> reads.incrementAndGet());
long length = lengthGen.nextLong(rs);
CompressionMetadata metadata1, metadata2;
try (CompressedSequentialWriter writer = new CompressedSequentialWriter(f, new File("/file.offset"), new File("/file.digest"), option, params, new MetadataCollector(new ClusteringComparator())))
@ -73,71 +71,45 @@ public class CompressedChunkReaderTest
metadata2 = writer.open(0);
}
doReads(f, metadata1, length, true);
int scanReads = reads.getAndSet(0);
doReads(f, metadata2, length, false);
doReads(f, metadata1, length, false);
int raReads = reads.getAndSet(0);
doReads(f, metadata2, length, true);
int scanReads = reads.getAndSet(0);
if (Files.size(f.toPath()) > DatabaseDescriptor.getCompressedReadAheadBufferSize())
Assert.assertTrue(scanReads < raReads);
Assert.assertTrue(scanReads <= raReads);
});
}
private void doReads(File f, CompressionMetadata metadata, long length, boolean useReadAhead)
protected void doReads(File f, CompressionMetadata metadata, long length, boolean useReadAhead)
{
ByteBuffer buffer = ByteBuffer.allocateDirect(metadata.chunkLength());
try (ChannelProxy channel = new ChannelProxy(f);
CompressedChunkReader reader = new CompressedChunkReader.Standard(channel, metadata, () -> 1.1);
metadata)
try (ChannelProxy channel = new ChannelProxy(f))
{
if (useReadAhead)
reader.forScan();
long offset = 0;
long maxOffset = length * Long.BYTES;
do
try (CompressedChunkReader reader = new CompressedChunkReader.Standard(channel, metadata, () -> 1d);
metadata)
{
reader.readChunk(offset, buffer);
for (long expected = offset / Long.BYTES; buffer.hasRemaining(); expected++)
Assertions.assertThat(buffer.getLong()).isEqualTo(expected);
if (useReadAhead)
reader.forScan();
offset += metadata.chunkLength();
long offset = 0;
long maxOffset = length * Long.BYTES;
do
{
reader.readChunk(offset, buffer);
for (long expected = offset / Long.BYTES; buffer.hasRemaining(); expected++)
Assertions.assertThat(buffer.getLong()).isEqualTo(expected);
offset += metadata.chunkLength();
}
while (offset < maxOffset);
}
while (offset < maxOffset);
}
finally
{
MemoryUtil.clean(buffer);
}}
private static Gen<SequentialWriterOption> options()
{
Gen<Integer> bufferSizes = Gens.constant(1 << 10); //.pickInt(1 << 4, 1 << 10, 1 << 15);
return rs -> SequentialWriterOption.newBuilder()
.finishOnClose(false)
.bufferSize(bufferSizes.next(rs))
.build();
}
private enum CompressionKind { Noop, Snappy, Deflate, Lz4, Zstd }
private static Gen<CompressionParams> params()
{
Gen<Integer> chunkLengths = Gens.constant(CompressionParams.DEFAULT_CHUNK_LENGTH);
Gen<Double> compressionRatio = Gens.pick(1.1D);
return rs -> {
CompressionKind kind = rs.pick(CompressionKind.values());
switch (kind)
{
case Noop: return CompressionParams.noop();
case Snappy: return CompressionParams.snappy(chunkLengths.next(rs), compressionRatio.next(rs));
case Deflate: return CompressionParams.deflate(chunkLengths.next(rs));
case Lz4: return CompressionParams.lz4(chunkLengths.next(rs));
case Zstd: return CompressionParams.zstd(chunkLengths.next(rs));
default: throw new UnsupportedOperationException(kind.name());
}
};
}
}
}

View File

@ -46,9 +46,9 @@ import static org.apache.cassandra.config.CassandraRelevantProperties.JAVA_IO_TM
public class ThreadLocalReadAheadBufferTest implements WithQuickTheories
{
private static final int numFiles = 5;
private static final File[] files = new File[numFiles];
private static final Logger logger = LoggerFactory.getLogger(ThreadLocalReadAheadBufferTest.class);
private static Integer seed;
protected static final File[] files = new File[numFiles];
protected static Integer seed;
@BeforeClass
public static void setup()
@ -74,7 +74,7 @@ public class ThreadLocalReadAheadBufferTest implements WithQuickTheories
}
catch (Exception e)
{
// ignore
// ignore
}
}
}
@ -89,83 +89,87 @@ public class ThreadLocalReadAheadBufferTest implements WithQuickTheories
@Test
public void testReadsLikeChannelProxy()
{
qt().withFixedSeed(seed).forAll(reads())
.checkAssert(this::testReads);
}
private void testReads(InputData propertyInputs)
protected void testReads(InputData propertyInputs)
{
try (ChannelProxy channel = new ChannelProxy(propertyInputs.file))
try (ChannelProxy channel = new ChannelProxy(propertyInputs.file);
ThreadLocalReadAheadBuffer tlrab = new ThreadLocalReadAheadBuffer(channel, new DataStorageSpec.IntKibibytesBound("256KiB").toBytes(), BufferType.OFF_HEAP); )
{
ThreadLocalReadAheadBuffer trlab = new ThreadLocalReadAheadBuffer(channel, new DataStorageSpec.IntKibibytesBound("256KiB").toBytes(), BufferType.OFF_HEAP);
for (Pair<Long, Integer> read : propertyInputs.positionsAndLengths)
{
int readSize = Math.min(read.right,(int) (channel.size() - read.left));
ByteBuffer buf1 = ByteBuffer.allocate(readSize);
channel.read(buf1, read.left);
ByteBuffer buf2 = ByteBuffer.allocate(readSize);
try
{
int copied = 0;
while (copied < readSize) {
trlab.fill(read.left + copied);
int leftToRead = readSize - copied;
if (trlab.remaining() >= leftToRead)
copied += trlab.read(buf2, leftToRead);
else
copied += trlab.read(buf2, trlab.remaining());
}
}
catch (CorruptSSTableException e)
{
throw new RuntimeException(e);
}
Assert.assertEquals(buf1, buf2);
testRead(read, channel, tlrab);
}
}
}
private Gen<InputData> reads()
protected static void testRead(Pair<Long, Integer> read, ChannelProxy bufferedChannel, ThreadLocalReadAheadBuffer tlrab)
{
int readSize = Math.min(read.right, (int) (bufferedChannel.size() - read.left));
ByteBuffer buf1 = ByteBuffer.allocate(readSize);
bufferedChannel.read(buf1, read.left);
ByteBuffer buf2 = ByteBuffer.allocate(readSize);
try
{
int copied = 0;
while (copied < readSize)
{
tlrab.fill(read.left + copied);
int leftToRead = readSize - copied;
if (tlrab.remaining() >= leftToRead)
copied += tlrab.read(buf2, leftToRead);
else
copied += tlrab.read(buf2, tlrab.remaining());
}
}
catch (CorruptSSTableException e)
{
throw new RuntimeException(e);
}
Assert.assertEquals(buf1, buf2);
}
protected Gen<InputData> reads()
{
return arbitrary().pick(List.of(files))
.flatMap((file) ->
lists().of(longs().between(0, fileSize(file)).zip(integers().between(1, 100), Pair::create))
.ofSizeBetween(5, 10)
.map(positionsAndLengths -> new InputData(file, positionsAndLengths)));
}
private Gen<InputData> lastBlockReads()
protected Gen<InputData> lastBlockReads()
{
int blockSize = new DataStorageSpec.IntKibibytesBound("256KiB").toBytes();
return arbitrary().pick(List.of(files))
.flatMap((file) ->
lists().of(longs().between(max(0, fileSize(file) - blockSize), fileSize(file)).zip(integers().between(1, 100), Pair::create))
.ofSizeBetween(5, 10)
.map(positionsAndLengths -> new InputData(file, positionsAndLengths)));
.flatMap((file) ->
lists().of(longs().between(max(0, fileSize(file) - blockSize), fileSize(file)).zip(integers().between(1, 100), Pair::create))
.ofSizeBetween(5, 10)
.map(positionsAndLengths -> new InputData(file, positionsAndLengths)));
}
// need this becasue generators don't handle the IOException
// need this because generators don't handle the IOException
private long fileSize(File file)
{
try
{
return Files.size(file.toPath());
} catch (IOException e)
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
private static class InputData
protected static class InputData
{
private final File file;
private final List<Pair<Long, Integer>> positionsAndLengths;
protected final File file;
protected final List<Pair<Long, Integer>> positionsAndLengths;
public InputData(File file, List<Pair<Long, Integer>> positionsAndLengths)
{
@ -201,5 +205,4 @@ public class ThreadLocalReadAheadBufferTest implements WithQuickTheories
return file;
}
}

View File

@ -529,4 +529,13 @@ public class StartupChecksTest
assertTrue(e.getMessage().contains(message));
}
}
@Test
public void testFindDirectIOUnsupportedLocationsSkipsNonExistentDirs()
{
// Non-existent directories should be skipped, not added to unsupported list
List<String> unsupported = StartupChecks.findDirectIOUnsupportedLocations(
new String[] { "/this/path/does/not/exist/for/testing" });
assertThat(unsupported).isEmpty();
}
}