Skip to content

Commit 104a65d

Browse files
CASSANDRA-21134: Fix O_DIRECT short reads on early-open of compaction SSTables
Early-open publishes readers before commit pads/truncates the file. Under O_DIRECT this exposed not-yet-durable or block-padded tails, so early readers short-read past EOF (CorruptBlockException/CorruptSSTableException). Fix all three early-open paths inside the DIO writer subclass: - final early-open: override syncInternal() to flush the buffered sub-block tail, restoring channel/buffer cursors so the commit-time pad+truncate still yields a byte-identical file. - preemptive early-open (SSTableRewriter.maybeReopenEarly): report the durable uncompressed offset by tracking each staged chunk's {compressedEnd, uncompressedEnd} and advancing only over chunks whose compressed bytes sit below fchannel.position(). No extra I/O. - scan early-open: truncate to actualDataSize at openFinalEarly so a scanner's snapshotted file size already equals the committed size.
1 parent 3ea34f1 commit 104a65d

2 files changed

Lines changed: 257 additions & 0 deletions

File tree

src/java/org/apache/cassandra/io/compress/DirectCompressedSequentialWriter.java

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919

2020
import java.io.IOException;
2121
import java.nio.ByteBuffer;
22+
import java.util.ArrayDeque;
2223
import java.util.concurrent.atomic.AtomicBoolean;
2324
import java.util.function.IntConsumer;
25+
import java.util.function.LongConsumer;
2426

2527
import javax.annotation.Nullable;
2628

@@ -34,6 +36,7 @@
3436

3537
import org.apache.cassandra.config.DatabaseDescriptor;
3638
import org.apache.cassandra.db.compression.CompressionDictionaryManager;
39+
import org.apache.cassandra.io.FSReadError;
3740
import org.apache.cassandra.io.FSWriteError;
3841
import org.apache.cassandra.io.sstable.metadata.MetadataCollector;
3942
import org.apache.cassandra.io.util.ChecksumWriter;
@@ -78,6 +81,12 @@ public class DirectCompressedSequentialWriter extends CompressedSequentialWriter
7881

7982
private final int blockSize;
8083

84+
// Chunks staged in writeBuffer but not yet on disk, in write order, each as {compressedEnd, uncompressedEnd}.
85+
// Drained as whole blocks reach disk so the post-flush listener can report a durable offset (see
86+
// setPostFlushListener). A chunk costs one small array per ~chunkLength written — negligible churn.
87+
private final ArrayDeque<long[]> stagedChunkBoundaries = new ArrayDeque<>();
88+
private long durableUncompressedOffset = 0;
89+
8190
public DirectCompressedSequentialWriter(File file,
8291
File offsetsFile,
8392
@Nullable File digestFile,
@@ -148,6 +157,27 @@ protected void seekToChunkStart()
148157
// resetAndTruncate (the parent's reason for this seek) is unsupported under DIO.
149158
}
150159

160+
// openFinalEarly() publishes a reader right after sync(), before commit. The inherited sync flushes
161+
// only whole blocks, so under O_DIRECT the last chunk's sub-block tail is still buffered and that
162+
// reader short-reads past EOF — flush the tail here too.
163+
@Override
164+
protected void syncInternal()
165+
{
166+
doFlush(0);
167+
flushBufferedTailForEarlyOpen();
168+
syncDataOnlyInternal();
169+
}
170+
171+
// flushData fires this per chunk with the staged (uncompressed) offset, but under O_DIRECT a chunk is
172+
// only readable once its block reaches disk. Preemptive early-open (markDataSynced) publishes a reader
173+
// as soon as this offset covers its boundary, so reporting the staged offset would expose chunks still
174+
// in writeBuffer. Report the durable offset instead; the boundary simply waits for the next block flush.
175+
@Override
176+
public void setPostFlushListener(LongConsumer postFlush)
177+
{
178+
super.setPostFlushListener(stagedOffset -> postFlush.accept(durableUncompressedOffset()));
179+
}
180+
151181
@Override
152182
protected void writeChunk(ByteBuffer toWrite)
153183
{
@@ -164,6 +194,7 @@ protected void writeChunk(ByteBuffer toWrite)
164194
crcMetadata.appendDirect(toWrite, true);
165195

166196
actualDataSize = chunkOffset + chunkLength + CRC_LENGTH;
197+
stagedChunkBoundaries.add(new long[]{ actualDataSize, uncompressedSize });
167198
}
168199

169200
private void writeToAlignedBuffer(ByteBuffer data)
@@ -229,6 +260,57 @@ private void flushCompleteBlocks()
229260
}
230261
}
231262

263+
// Truncate to actualDataSize so an early-open scan reader, which snapshots the physical file size once and
264+
// bounds its reads by it, isn't shrunk under by commit's truncate into short-reading its last block. (Point
265+
// reads are immune: they bound by compressedFileLength.) O_DIRECT forces the pad-to-a-block before the
266+
// truncate. Restoring the cursors leaves the writer untouched so commit reproduces the identical file and
267+
// writes resume at resumeChannelPos (always <= actualDataSize, so the truncate never strands the offset).
268+
private void flushBufferedTailForEarlyOpen()
269+
{
270+
int logicalPos = writeBuffer.position();
271+
if (logicalPos == 0)
272+
return;
273+
274+
try
275+
{
276+
long resumeChannelPos = fchannel.position();
277+
writeAlignedBlocks(BitUtil.align(logicalPos, blockSize));
278+
fchannel.truncate(actualDataSize);
279+
280+
fchannel.position(resumeChannelPos);
281+
writeBuffer.limit(writeBuffer.capacity());
282+
writeBuffer.position(logicalPos);
283+
}
284+
catch (IOException e)
285+
{
286+
throw new FSWriteError(e, getPath());
287+
}
288+
}
289+
290+
// Advance over chunks whose compressed bytes now sit entirely below the on-disk boundary
291+
// (fchannel.position() — only whole blocks are written). Chunks flush in order, so a single drain
292+
// suffices and the offset is monotonic.
293+
private long durableUncompressedOffset()
294+
{
295+
long onDisk;
296+
try
297+
{
298+
onDisk = fchannel.position();
299+
}
300+
catch (IOException e)
301+
{
302+
throw new FSReadError(e, getPath());
303+
}
304+
305+
long[] chunk;
306+
while ((chunk = stagedChunkBoundaries.peek()) != null && chunk[0] <= onDisk)
307+
{
308+
durableUncompressedOffset = chunk[1];
309+
stagedChunkBoundaries.poll();
310+
}
311+
return durableUncompressedOffset;
312+
}
313+
232314
private void flushFinalWithPadding()
233315
{
234316
int logicalPos = writeBuffer.position();

test/unit/org/apache/cassandra/io/compress/DirectCompressedSequentialWriterTest.java

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -810,6 +810,181 @@ public void testOnDiskFilePointerTracksActualDataSize() throws IOException
810810
}
811811
}
812812

813+
/**
814+
* Covers the early-open read path: openFinalEarly() publishes a reader after sync() but before
815+
* commit, and under O_DIRECT the last chunk's sub-block tail is still buffered then, so without the
816+
* sync() tail-flush the reader short-reads past EOF. The other tests miss this window — they all read
817+
* post-finish, once the tail is flushed. Tiny sub-block outputs (e.g. system/IndexInfo) are the prod shape.
818+
*/
819+
@Test
820+
public void testEarlyOpenAfterSyncReadsBackData() throws IOException
821+
{
822+
for (int size : new int[]{ 1, 64, 298, 1024, 4096, DEFAULT_CHUNK_LENGTH * 3 + 137 })
823+
assertEarlyOpenReadsBack(compressible(size), CompressionParams.lz4());
824+
}
825+
826+
// Compresses well below one block, so after sync() the whole chunk+CRC is still buffered and the
827+
// on-disk file is empty — the worst case for the early-open read.
828+
private static byte[] compressible(int size)
829+
{
830+
byte[] data = new byte[size];
831+
for (int i = 0; i < size; i++)
832+
data[i] = (byte) (i % 7);
833+
return data;
834+
}
835+
836+
private static void assertEarlyOpenReadsBack(byte[] payload, CompressionParams params) throws IOException
837+
{
838+
File dataFile = FileUtils.createTempFile("early_open_direct", ".db");
839+
File metadataFile = new File(dataFile.absolutePath() + ".metadata");
840+
try
841+
{
842+
MetadataCollector collector = newCollector();
843+
try (DirectCompressedSequentialWriter writer = new DirectCompressedSequentialWriter(
844+
dataFile, metadataFile, null, SequentialWriterOption.DEFAULT, params, collector, null))
845+
{
846+
writer.write(payload);
847+
848+
// Mirror openFinalEarly: sync(), then open(0) == openDataFile(NO_LENGTH_OVERRIDE).
849+
writer.sync();
850+
try (CompressionMetadata md = writer.open(0);
851+
FileHandle fh = new FileHandle.Builder(dataFile).withCompressionMetadata(md).complete();
852+
RandomAccessReader reader = fh.createReader())
853+
{
854+
assertEquals("early-open length mismatch, size=" + payload.length, payload.length, reader.length());
855+
byte[] readBack = new byte[payload.length];
856+
reader.readFully(readBack);
857+
assertArrayEquals("early-open read-back mismatch, size=" + payload.length, payload, readBack);
858+
}
859+
860+
writer.finish();
861+
}
862+
}
863+
finally
864+
{
865+
dataFile.tryDelete();
866+
metadataFile.tryDelete();
867+
}
868+
}
869+
870+
/**
871+
* Covers the preemptive early-open read path (SSTableRewriter.maybeReopenEarly): unlike openFinalEarly
872+
* it never calls sync(). The partition index advances its readable boundary off the writer's post-flush
873+
* offset, then a reader is published over that boundary mid-compaction. Under O_DIRECT only whole blocks
874+
* reach disk during flushData, so the writer must report the durable offset, not the staged one —
875+
* otherwise the reader short-reads chunks still parked in writeBuffer. testEarlyOpenAfterSyncReadsBackData
876+
* does not cover this — its sync() flushes the tail; this window has no sync at all.
877+
*/
878+
@Test
879+
public void testPreemptiveOpenReadsBackSyncedData() throws IOException
880+
{
881+
// Many small chunks that compress far below one block: nothing reaches disk before finish, so the
882+
// writer must report 0 synced (the prod system/IndexInfo shape) rather than the staged length.
883+
assertSyncedOffsetIsReadable(compressible(DEFAULT_CHUNK_LENGTH * 4 + 137), CompressionParams.lz4());
884+
885+
// Several MiB of incompressible data forces real block flushes mid-write, so the synced boundary
886+
// must advance past zero (preemptive open still works) yet never cross the still-buffered tail.
887+
long synced = assertSyncedOffsetIsReadable(incompressible(4 << 20), CompressionParams.lz4());
888+
assertTrue("expected the synced boundary to advance for multi-MiB data", synced > 0);
889+
}
890+
891+
// Mirrors how a preemptive early-open reader is bounded: whatever offset the writer reports as synced
892+
// (via the post-flush listener that drives PartitionIndexBuilder.markDataSynced) must be fully readable
893+
// from disk, with no sync()/finish() in between. Returns that synced offset.
894+
private static long assertSyncedOffsetIsReadable(byte[] payload, CompressionParams params) throws IOException
895+
{
896+
File dataFile = FileUtils.createTempFile("preemptive_open_direct", ".db");
897+
File metadataFile = new File(dataFile.absolutePath() + ".metadata");
898+
try
899+
{
900+
MetadataCollector collector = newCollector();
901+
try (DirectCompressedSequentialWriter writer = new DirectCompressedSequentialWriter(
902+
dataFile, metadataFile, null, SequentialWriterOption.DEFAULT, params, collector, null))
903+
{
904+
long[] syncedOffset = { 0 };
905+
writer.setPostFlushListener(offset -> syncedOffset[0] = offset);
906+
907+
writer.write(payload);
908+
909+
int readable = (int) syncedOffset[0];
910+
if (readable > 0)
911+
{
912+
try (CompressionMetadata md = writer.open(readable);
913+
FileHandle fh = new FileHandle.Builder(dataFile).withCompressionMetadata(md).complete();
914+
RandomAccessReader reader = fh.createReader())
915+
{
916+
byte[] readBack = new byte[readable];
917+
reader.readFully(readBack);
918+
assertArrayEquals("preemptive-open read-back mismatch", Arrays.copyOf(payload, readable), readBack);
919+
}
920+
}
921+
922+
writer.finish();
923+
return syncedOffset[0];
924+
}
925+
}
926+
finally
927+
{
928+
dataFile.tryDelete();
929+
metadataFile.tryDelete();
930+
}
931+
}
932+
933+
private static byte[] incompressible(int size)
934+
{
935+
byte[] data = new byte[size];
936+
new Random(42).nextBytes(data);
937+
return data;
938+
}
939+
940+
/**
941+
* Regression test for the early-open SCAN read path, which the point-read early-open tests
942+
* ({@link #testEarlyOpenAfterSyncReadsBackData}, {@link #testPreemptiveOpenReadsBackSyncedData}) miss: a
943+
* scanner snapshots the data file's size when it opens, so if openFinalEarly leaves the file padded and
944+
* commit later truncates it, the live scanner short-reads its last block (CorruptSSTableException — the
945+
* system/local_metadata_log failure seen under dtest-latest). Tiny sub-block outputs are the worst case.
946+
*/
947+
@Test
948+
public void testEarlyOpenScanReadsBackAfterCommitTruncate() throws IOException
949+
{
950+
for (int size : new int[]{ 1, 298, 1024, DEFAULT_CHUNK_LENGTH * 3 + 137 })
951+
assertEarlyOpenScanReadsBackAcrossCommit(compressible(size), CompressionParams.lz4());
952+
}
953+
954+
// Ordering is the whole point: the scanner must open before finish() so it caches the file size while
955+
// padded, then finish() truncates underneath it.
956+
private static void assertEarlyOpenScanReadsBackAcrossCommit(byte[] payload, CompressionParams params) throws IOException
957+
{
958+
File dataFile = FileUtils.createTempFile("early_open_scan_direct", ".db");
959+
File metadataFile = new File(dataFile.absolutePath() + ".metadata");
960+
try
961+
{
962+
MetadataCollector collector = newCollector();
963+
try (DirectCompressedSequentialWriter writer = new DirectCompressedSequentialWriter(
964+
dataFile, metadataFile, null, SequentialWriterOption.DEFAULT, params, collector, null))
965+
{
966+
writer.write(payload);
967+
writer.sync();
968+
try (CompressionMetadata md = writer.open(0);
969+
FileHandle fh = new FileHandle.Builder(dataFile).withCompressionMetadata(md).complete();
970+
// forScan=true engages the read-ahead path; the scanner snapshots channel.size() here.
971+
RandomAccessReader scanReader = fh.createReader(null, true))
972+
{
973+
writer.finish();
974+
975+
byte[] readBack = new byte[payload.length];
976+
scanReader.readFully(readBack);
977+
assertArrayEquals("early-open scan read-back mismatch, size=" + payload.length, payload, readBack);
978+
}
979+
}
980+
}
981+
finally
982+
{
983+
dataFile.tryDelete();
984+
metadataFile.tryDelete();
985+
}
986+
}
987+
813988
private void testWriteAndRead(String testName, int dataSize, CompressionParams params) throws IOException
814989
{
815990
File dataFile = FileUtils.createTempFile(testName + "_direct", ".db");

0 commit comments

Comments
 (0)