@@ -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