Skip to content

Commit 5cc68a9

Browse files
authored
fix(qwp): recover an SF slot whose segment tail is an unbacked mapped page (#64)
1 parent e2d1eea commit 5cc68a9

7 files changed

Lines changed: 771 additions & 47 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java

Lines changed: 182 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public final class MmapSegment implements QuietCloseable {
6464
public static final int FRAME_HEADER_SIZE = 8; // u32 crc + u32 payloadLen
6565
public static final int HEADER_SIZE = 24;
6666
public static final byte VERSION = 1;
67+
private static final int[] CRC32C_TABLE = buildCrc32cTable();
6768
private static final Logger LOG = LoggerFactory.getLogger(MmapSegment.class);
6869

6970
private final String path;
@@ -257,11 +258,31 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) {
257258
* last valid frame) do not log and report {@code 0}.
258259
*/
259260
public static MmapSegment openExisting(String path) {
260-
long fileSize = Files.length(path);
261+
return openExisting(FilesFacade.INSTANCE, path);
262+
}
263+
264+
/**
265+
* Facade-aware variant of {@link #openExisting(String)} that takes the file
266+
* length (and open/close) through a {@link FilesFacade} instead of straight
267+
* off {@link Files}. Production uses {@link FilesFacade#INSTANCE}; the seam
268+
* exists so recovery's mmap-fault guard can be regression-tested on any
269+
* filesystem.
270+
* <p>
271+
* The mapping is sized to {@code ff.length(path)} and every recovery read
272+
* runs straight out of it, so a facade that reports a length <em>larger</em>
273+
* than the real file makes the mapping extend past end-of-file. A read of a
274+
* page beyond real EOF raises SIGBUS on <em>every</em> filesystem — the same
275+
* fault an unbacked/sparse page raises on ZFS, but reproduced
276+
* deterministically on ext4/xfs, where a within-EOF hole would instead
277+
* zero-fill and never exercise the guard. See
278+
* {@link FilesFacade#length(String)}.
279+
*/
280+
public static MmapSegment openExisting(FilesFacade ff, String path) {
281+
long fileSize = ff.length(path);
261282
if (fileSize < HEADER_SIZE) {
262283
throw new MmapSegmentException("file shorter than header: " + path + " size=" + fileSize);
263284
}
264-
int fd = Files.openRW(path);
285+
int fd = ff.openRW(path);
265286
if (fd < 0) {
266287
throw new MmapSegmentException("openRW failed for " + path);
267288
}
@@ -308,7 +329,24 @@ public static MmapSegment openExisting(String path) {
308329
if (addr != Files.FAILED_MMAP_ADDRESS) {
309330
Files.munmap(addr, fileSize, MemoryTag.MMAP_DEFAULT);
310331
}
311-
Files.close(fd);
332+
ff.close(fd);
333+
// The header reads above (magic/version/baseSeq) run before
334+
// scanFrames and are otherwise unguarded. An unbacked page 0 --
335+
// an unflushed header on a truncate-based-allocate filesystem
336+
// after a crash (create() does not msync), or a file truncated
337+
// under the mapping -- faults at the Unsafe intrinsic site as a
338+
// catchable InternalError. Convert it to a MmapSegmentException so
339+
// SegmentRing's per-file catch skips just this .sfa, instead of
340+
// letting the raw InternalError escape to SegmentRing's outer catch
341+
// and abort recovery of every sibling segment. scanFrames and
342+
// detectTornTail already handle their own in-mapping faults; this
343+
// covers the header block and any future reader placed ahead of the
344+
// scan.
345+
if (isMmapAccessFault(t)) {
346+
throw new MmapSegmentException(
347+
"unreadable mapped header page in " + path
348+
+ " (unbacked/sparse page 0): " + t.getMessage(), t);
349+
}
312350
throw t;
313351
}
314352
}
@@ -501,11 +539,47 @@ public long frameCount() {
501539
* fresh segments, memory-backed segments, and cleanly partially-filled
502540
* recovered segments. Operators / tests can read this to tell silent
503541
* truncation (corruption) from a normal partial fill (no incident).
542+
* <p>
543+
* One case this does NOT count: when the scan stops because the bail-out
544+
* region is itself an unbacked mapped page (an in-mapping fault, not a
545+
* backed torn header), that region cannot be probed, so this returns
546+
* {@code 0} even though frames may have been discarded. That outcome is
547+
* surfaced by the {@code WARN} in {@link #scanFrames} instead -- see it for
548+
* the benign-tail-vs-media-error caveat.
504549
*/
505550
public long tornTailBytes() {
506551
return tornTailBytes;
507552
}
508553

554+
/**
555+
* True when {@code t} is the JVM's recoverable signal for a fault while
556+
* accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot
557+
* translates into an {@code InternalError} at an {@code Unsafe} intrinsic
558+
* site instead of aborting the process. It surfaces when a mapped page is
559+
* not backed by real file blocks: a sparse {@code .sfa} tail on a
560+
* filesystem whose pre-allocation leaves holes (e.g. ZFS, where a
561+
* truncate-based {@code allocate} does not materialize blocks), or a file
562+
* truncated under the mapping. Recovery treats this as an I/O boundary --
563+
* the same way MappedByteBuffer readers do -- not a fatal VM error.
564+
* <p>
565+
* The message is matched on the fragment {@code "unsafe memory access
566+
* operation"}, which is common to both HotSpot wordings and NOT
567+
* version-stable as a whole: pre-21 JDKs (including the shipping/CI JDK 8)
568+
* emit {@code "a fault occurred in a recent unsafe memory access operation
569+
* in compiled Java code"}, while JDK 21+ shortened it to {@code "a fault
570+
* occurred in an unsafe memory access operation"}. Matching the shared
571+
* fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while
572+
* still being specific enough that a genuine VirtualMachineError (real OOM,
573+
* StackOverflow) is never swallowed.
574+
*/
575+
private static boolean isMmapAccessFault(Throwable t) {
576+
if (!(t instanceof InternalError)) {
577+
return false;
578+
}
579+
String msg = t.getMessage();
580+
return msg != null && msg.contains("unsafe memory access operation");
581+
}
582+
509583
/**
510584
* Forward scan that returns the offset just past the last frame whose
511585
* CRC verifies. A torn-tail frame (declared length runs past EOF, or
@@ -515,26 +589,103 @@ public long tornTailBytes() {
515589
*/
516590
private static long scanFrames(long addr, long fileSize) {
517591
long pos = HEADER_SIZE;
518-
while (pos + FRAME_HEADER_SIZE <= fileSize) {
519-
int crcRead = Unsafe.getUnsafe().getInt(addr + pos);
520-
int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4);
521-
// Defensive: a corrupt length field could be enormous or negative,
522-
// both of which would otherwise overrun the mapping.
523-
if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) {
524-
return pos;
525-
}
526-
int crcCalc = Crc32c.update(Crc32c.INIT, addr + pos + 4, 4);
527-
if (payloadLen > 0) {
528-
crcCalc = Crc32c.update(crcCalc, addr + pos + FRAME_HEADER_SIZE, payloadLen);
592+
try {
593+
while (pos + FRAME_HEADER_SIZE <= fileSize) {
594+
int crcRead = Unsafe.getUnsafe().getInt(addr + pos);
595+
int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4);
596+
// Defensive: a corrupt length field could be enormous or negative,
597+
// both of which would otherwise overrun the mapping.
598+
if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) {
599+
return pos;
600+
}
601+
// CRC over the contiguous (payloadLen, payload) pair, folded
602+
// via Unsafe reads rather than the native Crc32c.update.
603+
// Recovery maps to the file's stat length, but a page inside
604+
// that range can be unbacked: a sparse pre-allocation tail (a
605+
// truncate-based allocate that never materialized blocks, as on
606+
// ZFS), or -- via a torn write, since tryAppend writes the
607+
// length field before copying the payload -- a real positive
608+
// payloadLen whose payload spans into an unwritten hole. A raw
609+
// read of an unbacked page raises SIGBUS; HotSpot translates
610+
// that into a catchable InternalError ONLY at an Unsafe
611+
// intrinsic site, NEVER inside JNI native code, so a native
612+
// Crc32c.update over such a page aborts the whole JVM (and,
613+
// empirically on pre-21 JDKs, a preceding Unsafe pre-touch does
614+
// not reliably fault first once an earlier native CRC ran in
615+
// the same scan). Folding over Unsafe keeps every fault
616+
// catchable -- handled below as the boundary of recoverable
617+
// data; a page that instead reads back as zeroes just fails the
618+
// CRC check and ends the scan. Recovery is cold, so the slower
619+
// table CRC here is immaterial.
620+
int crcCalc = crc32cRecovery(addr + pos + 4, 4L + payloadLen);
621+
if (crcCalc != crcRead) {
622+
return pos;
623+
}
624+
pos += FRAME_HEADER_SIZE + payloadLen;
529625
}
530-
if (crcCalc != crcRead) {
531-
return pos;
626+
} catch (InternalError e) {
627+
// The read at `pos` hit a mapped page that is not backed by real
628+
// file blocks: the JVM translates the underlying SIGBUS into a
629+
// recoverable InternalError instead of aborting the process. This
630+
// happens when a prior session left a sparse segment tail (a
631+
// truncate-based pre-allocation that does not materialize blocks,
632+
// as on ZFS) or the file was truncated under the mapping. Every
633+
// frame below `pos` already verified; treat the unreadable region
634+
// exactly like unwritten space or a torn tail -- the boundary of
635+
// recoverable data -- rather than letting the error abort recovery
636+
// of the whole slot. Anything that is not the documented mmap
637+
// access fault is a genuine VM error, so rethrow it.
638+
if (!isMmapAccessFault(e)) {
639+
throw e;
532640
}
533-
pos += FRAME_HEADER_SIZE + payloadLen;
641+
LOG.warn("SF segment recovery: unreadable mapped page at offset {} (file size {}); "
642+
+ "treating it as the end of recoverable data -- any frames beyond this "
643+
+ "offset are discarded. The usual cause is a benign sparse pre-allocation "
644+
+ "tail (e.g. a truncate-based allocate on ZFS) left by a prior session, "
645+
+ "but a mid-file media error (bad sector) is indistinguishable here; "
646+
+ "check disk health if this segment was expected to be fully written or "
647+
+ "if this recurs.",
648+
pos, fileSize);
534649
}
535650
return pos;
536651
}
537652

653+
/**
654+
* CRC-32C (Castagnoli) of {@code [addr, addr + len)} read through
655+
* {@link Unsafe}, seeded like {@code Crc32c.update(Crc32c.INIT, addr, len)}
656+
* and bit-identical to it (verified) -- but every byte load is an Unsafe
657+
* intrinsic, so a fault on an unbacked mapped page is a catchable
658+
* {@link InternalError} instead of the uncatchable JNI SIGBUS the native
659+
* {@link Crc32c} would raise. Byte-at-a-time via a precomputed table
660+
* ({@code ~0.5 GiB/s}); used only on the cold recovery scan, never on the
661+
* append hot path (which stays on the native, hardware-friendly path).
662+
*/
663+
private static int crc32cRecovery(long addr, long len) {
664+
int crc = ~Crc32c.INIT;
665+
for (long i = 0; i < len; i++) {
666+
crc = (crc >>> 8) ^ CRC32C_TABLE[(crc ^ Unsafe.getUnsafe().getByte(addr + i)) & 0xFF];
667+
}
668+
return ~crc;
669+
}
670+
671+
/**
672+
* Standard reflected CRC-32C byte table (polynomial {@code 0x82F63B78}),
673+
* matching {@code crc32c_table[0]} in the native {@code crc32c.c}. Computed
674+
* at class init to avoid 256 hand-transcribed literals; drives
675+
* {@link #crc32cRecovery}.
676+
*/
677+
private static int[] buildCrc32cTable() {
678+
int[] table = new int[256];
679+
for (int n = 0; n < 256; n++) {
680+
int c = n;
681+
for (int k = 0; k < 8; k++) {
682+
c = (c & 1) != 0 ? (0x82F63B78 ^ (c >>> 1)) : (c >>> 1);
683+
}
684+
table[n] = c;
685+
}
686+
return table;
687+
}
688+
538689
/**
539690
* Distinguishes "torn tail" (writer attempted a write past the last valid
540691
* frame and failed — partial write, mid-stream corruption, bit rot) from
@@ -553,10 +704,21 @@ private static long detectTornTail(long addr, long lastGood, long fileSize) {
553704
return 0L;
554705
}
555706
long probe = Math.min(FRAME_HEADER_SIZE, fileSize - lastGood);
556-
for (long i = 0; i < probe; i++) {
557-
if (Unsafe.getUnsafe().getByte(addr + lastGood + i) != 0) {
558-
return fileSize - lastGood;
707+
try {
708+
for (long i = 0; i < probe; i++) {
709+
if (Unsafe.getUnsafe().getByte(addr + lastGood + i) != 0) {
710+
return fileSize - lastGood;
711+
}
559712
}
713+
} catch (InternalError e) {
714+
// The bail-out region is an unbacked (sparse) mapped page -- see
715+
// scanFrames for the mechanism. An unbacked hole was never written,
716+
// so it is clean unwritten space, not a torn write. Rethrow any
717+
// error that is not the recoverable mmap access fault.
718+
if (!isMmapAccessFault(e)) {
719+
throw e;
720+
}
721+
return 0L;
560722
}
561723
return 0L;
562724
}

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,12 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
265265
// pivot would degrade back to O(N²) on exactly that common case.
266266
sortByBaseSeq(opened, 0, opened.size());
267267
// Sanity: the recovered segments must form a contiguous FSN range.
268-
// Detect gaps so a partial-write/manual-deletion mishap doesn't
269-
// silently produce duplicate or missing FSNs after recovery.
268+
// Detect gaps so they don't silently produce duplicate or missing
269+
// FSNs after recovery. A gap means a segment went missing (a
270+
// manual deletion) or a sealed segment under-recovered -- its tail
271+
// was cut short by a sparse/unbacked page or a mid-file media error
272+
// (bad sector), the same class of fault scanFrames tolerates on the
273+
// active segment but which corrupts the range on a sealed one.
270274
for (int i = 1, n = opened.size(); i < n; i++) {
271275
MmapSegment prev = opened.get(i - 1);
272276
MmapSegment curr = opened.get(i);
@@ -276,7 +280,10 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
276280
"FSN gap in recovered segments: prev baseSeq=" + prev.baseSeq()
277281
+ " frameCount=" + prev.frameCount()
278282
+ " expected next baseSeq=" + expected
279-
+ " but got " + curr.baseSeq());
283+
+ " but got " + curr.baseSeq()
284+
+ " -- a segment was deleted, or a sealed segment's tail was"
285+
+ " truncated (sparse/unbacked page or disk media error);"
286+
+ " check disk health");
280287
}
281288
}
282289
// The newest segment becomes the active. Even if it's full, that's OK:

core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ public long length(int fd) {
9191
return Files.length(fd);
9292
}
9393

94+
@Override
95+
public long length(String path) {
96+
return Files.length(path);
97+
}
98+
9499
@Override
95100
public int lock(int fd) {
96101
return Files.lock(fd);

core/src/main/java/io/questdb/client/std/FilesFacade.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ public interface FilesFacade {
8787

8888
long length(int fd);
8989

90+
/**
91+
* Stat length of the file at {@code path}, in bytes. Default delegates to
92+
* {@link Files#length(String)}.
93+
*
94+
* <p>Test injection point: {@code MmapSegment.openExisting} maps the file to
95+
* this length and scans straight out of the mapping, so a wrapping facade
96+
* that returns a value <em>larger</em> than the real file makes the mapping
97+
* extend past end-of-file. A read of a page beyond real EOF raises SIGBUS on
98+
* <em>every</em> filesystem (which HotSpot translates to a catchable
99+
* {@code InternalError} at an {@code Unsafe} intrinsic site) — the same fault
100+
* a genuinely unbacked/sparse page raises on ZFS, but reproduced
101+
* deterministically on ext4/xfs too. That is what lets recovery's mmap-fault
102+
* guard be regression-tested on any CI runner rather than only on ZFS.
103+
*/
104+
long length(String path);
105+
90106
int lock(int fd);
91107

92108
int mkdir(String path, int mode);

0 commit comments

Comments
 (0)