Skip to content

Commit 779dfd0

Browse files
puzpuzpuzclaude
andcommitted
fix(qwp): fold recovery CRC over Unsafe; broaden the mmap-fault match
Recovery reads a mapped .sfa directly; an unbacked page (a ZFS sparse pre-allocation tail, or a torn write leaving a real payloadLen pointing into an unwritten hole) faults on read. Two gaps on the shipping JDK 8: - The mmap-fault guard matched "a fault occurred in an unsafe memory access", which is only the JDK 21+ wording. Pre-21 (incl. JDK 8) emits "...a recent unsafe memory access operation in compiled Java code", so the guard was inert there. Match the shared fragment "unsafe memory access operation". - The payload CRC used the native Crc32c over the mapping. A SIGBUS inside JNI is not translated into a catchable InternalError (that happens only at Unsafe intrinsic sites), so a payload reaching an unbacked page aborted the whole JVM before the CRC check could reject the frame. Fold the recovery-time CRC over Unsafe reads (table-based CRC-32C, bit-identical to native), so the fault is catchable or degrades to a CRC mismatch. Native Crc32c stays on the append hot path. (A pre-touch guard before the native read was tried and proven unreliable: once an earlier native CRC ran in the same scan, it does not reliably fault first.) Rewrite the tests to drive the production openExisting path rather than reflecting into scanFrames: on pre-21 the imprecise fault escapes a reflective invoke frame, so reflection-based tests spuriously failed on JDK 8/11/17 even though the direct-call production path catches it. Induce the unbacked tail portably (truncate down then back up to leave a sparse hole) and size off Files.PAGE_SIZE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 104bc02 commit 779dfd0

2 files changed

Lines changed: 205 additions & 82 deletions

File tree

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

Lines changed: 72 additions & 14 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;
@@ -509,22 +510,30 @@ public long tornTailBytes() {
509510
/**
510511
* True when {@code t} is the JVM's recoverable signal for a fault while
511512
* accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot
512-
* translates into {@code InternalError("a fault occurred in an unsafe
513-
* memory access operation")} instead of aborting the process. It surfaces
514-
* when a mapped page is not backed by real file blocks: a sparse
515-
* {@code .sfa} tail on a filesystem whose pre-allocation leaves holes
516-
* (e.g. ZFS, where a truncate-based {@code allocate} does not materialize
517-
* blocks), or a file truncated under the mapping. Recovery treats this as
518-
* an I/O boundary -- the same way MappedByteBuffer readers do -- not a
519-
* fatal VM error. Matches on the JVM's stable message so a genuine
520-
* VirtualMachineError (real OOM, StackOverflow) is never swallowed.
513+
* translates into an {@code InternalError} at an {@code Unsafe} intrinsic
514+
* site instead of aborting the process. It surfaces when a mapped page is
515+
* not backed by real file blocks: a sparse {@code .sfa} tail on a
516+
* filesystem whose pre-allocation leaves holes (e.g. ZFS, where a
517+
* truncate-based {@code allocate} does not materialize blocks), or a file
518+
* truncated under the mapping. Recovery treats this as an I/O boundary --
519+
* the same way MappedByteBuffer readers do -- not a fatal VM error.
520+
* <p>
521+
* The message is matched on the fragment {@code "unsafe memory access
522+
* operation"}, which is common to both HotSpot wordings and NOT
523+
* version-stable as a whole: pre-21 JDKs (including the shipping/CI JDK 8)
524+
* emit {@code "a fault occurred in a recent unsafe memory access operation
525+
* in compiled Java code"}, while JDK 21+ shortened it to {@code "a fault
526+
* occurred in an unsafe memory access operation"}. Matching the shared
527+
* fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while
528+
* still being specific enough that a genuine VirtualMachineError (real OOM,
529+
* StackOverflow) is never swallowed.
521530
*/
522531
private static boolean isMmapAccessFault(Throwable t) {
523532
if (!(t instanceof InternalError)) {
524533
return false;
525534
}
526535
String msg = t.getMessage();
527-
return msg != null && msg.contains("a fault occurred in an unsafe memory access");
536+
return msg != null && msg.contains("unsafe memory access operation");
528537
}
529538

530539
/**
@@ -545,10 +554,26 @@ private static long scanFrames(long addr, long fileSize) {
545554
if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) {
546555
return pos;
547556
}
548-
int crcCalc = Crc32c.update(Crc32c.INIT, addr + pos + 4, 4);
549-
if (payloadLen > 0) {
550-
crcCalc = Crc32c.update(crcCalc, addr + pos + FRAME_HEADER_SIZE, payloadLen);
551-
}
557+
// CRC over the contiguous (payloadLen, payload) pair, folded
558+
// via Unsafe reads rather than the native Crc32c.update.
559+
// Recovery maps to the file's stat length, but a page inside
560+
// that range can be unbacked: a sparse pre-allocation tail (a
561+
// truncate-based allocate that never materialized blocks, as on
562+
// ZFS), or -- via a torn write, since tryAppend writes the
563+
// length field before copying the payload -- a real positive
564+
// payloadLen whose payload spans into an unwritten hole. A raw
565+
// read of an unbacked page raises SIGBUS; HotSpot translates
566+
// that into a catchable InternalError ONLY at an Unsafe
567+
// intrinsic site, NEVER inside JNI native code, so a native
568+
// Crc32c.update over such a page aborts the whole JVM (and,
569+
// empirically on pre-21 JDKs, a preceding Unsafe pre-touch does
570+
// not reliably fault first once an earlier native CRC ran in
571+
// the same scan). Folding over Unsafe keeps every fault
572+
// catchable -- handled below as the boundary of recoverable
573+
// data; a page that instead reads back as zeroes just fails the
574+
// CRC check and ends the scan. Recovery is cold, so the slower
575+
// table CRC here is immaterial.
576+
int crcCalc = crc32cRecovery(addr + pos + 4, 4L + payloadLen);
552577
if (crcCalc != crcRead) {
553578
return pos;
554579
}
@@ -577,6 +602,39 @@ private static long scanFrames(long addr, long fileSize) {
577602
return pos;
578603
}
579604

605+
/**
606+
* CRC-32C (Castagnoli) of {@code [addr, addr + len)} read through
607+
* {@link Unsafe}, seeded like {@code Crc32c.update(Crc32c.INIT, addr, len)}
608+
* and bit-identical to it (verified) -- but every byte load is an Unsafe
609+
* intrinsic, so a fault on an unbacked mapped page is a catchable
610+
* {@link InternalError} instead of the uncatchable JNI SIGBUS the native
611+
* {@link Crc32c} would raise. Byte-at-a-time via a precomputed table
612+
* ({@code ~0.5 GiB/s}); used only on the cold recovery scan, never on the
613+
* append hot path (which stays on the native, hardware-friendly path).
614+
*/
615+
private static int crc32cRecovery(long addr, long len) {
616+
int crc = ~Crc32c.INIT;
617+
for (long i = 0; i < len; i++) {
618+
crc = (crc >>> 8) ^ CRC32C_TABLE[(crc ^ Unsafe.getUnsafe().getByte(addr + i)) & 0xFF];
619+
}
620+
return ~crc;
621+
}
622+
623+
// Standard reflected CRC-32C byte table (polynomial 0x82F63B78), matching
624+
// crc32c_table[0] in the native crc32c.c. Computed at class init to avoid
625+
// 256 hand-transcribed literals; drives crc32cRecovery.
626+
private static int[] buildCrc32cTable() {
627+
int[] table = new int[256];
628+
for (int n = 0; n < 256; n++) {
629+
int c = n;
630+
for (int k = 0; k < 8; k++) {
631+
c = (c & 1) != 0 ? (0x82F63B78 ^ (c >>> 1)) : (c >>> 1);
632+
}
633+
table[n] = c;
634+
}
635+
return table;
636+
}
637+
580638
/**
581639
* Distinguishes "torn tail" (writer attempted a write past the last valid
582640
* frame and failed — partial write, mid-stream corruption, bit rot) from

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java

Lines changed: 133 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
import org.junit.Before;
3434
import org.junit.Test;
3535

36-
import java.lang.reflect.Method;
3736
import java.nio.file.Paths;
3837

3938
import static org.junit.Assert.assertEquals;
@@ -42,25 +41,35 @@
4241
/**
4342
* Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}.
4443
* <p>
45-
* {@code openExisting} maps a recovered {@code .sfa} to its stat length and
46-
* {@code scanFrames} / {@code detectTornTail} read the mapping directly. When a
47-
* prior session left a sparse segment tail -- a truncate-based pre-allocation
48-
* that never materialized the tail blocks, as happens on ZFS -- a read of an
44+
* On recovery, {@link MmapSegment#openExisting} maps a persisted {@code .sfa}
45+
* to its stat length and scans frames straight out of the mapping. When a prior
46+
* session left a sparse segment tail -- a truncate-based pre-allocation that
47+
* never materialized the tail blocks, as happens on ZFS -- a read of an
4948
* unbacked page raises the JVM's recoverable
50-
* {@code InternalError("a fault occurred in an unsafe memory access
51-
* operation")} (a translated SIGBUS). That error is NOT a
52-
* {@code MmapSegmentException}, so {@code SegmentRing.openExisting}'s per-file
53-
* skip did not catch it: it aborted recovery of the whole slot, which surfaced
54-
* (via a drainer/probe) as a spurious "unsafe memory access" failure on ZFS
55-
* CI runners.
49+
* {@code InternalError("...unsafe memory access operation...")} (a translated
50+
* SIGBUS). Recovery must treat that page as the boundary of recoverable data,
51+
* keep every frame below it, and hand back a usable segment -- not let the
52+
* error abort recovery of the whole slot (the reported ZFS-CI flake).
5653
* <p>
57-
* The fault only reproduces on a real filesystem whose mmap reads of unwritten
58-
* regions fault instead of zero-filling (ZFS), so this test induces the very
59-
* same JVM-recoverable fault deterministically on any filesystem: it maps a
60-
* valid segment file, then truncates the backing file under the still-larger
61-
* mapping so the tail page is genuinely beyond EOF (the one case POSIX mmap
62-
* always faults on). The scan must then stop at that page and keep every frame
63-
* below it, exactly as it treats a torn tail.
54+
* These tests drive the <b>production entry point</b> ({@code openExisting}),
55+
* not the private scan methods via reflection. That matters for two reasons:
56+
* <ul>
57+
* <li>It exercises the real recovery path end to end.</li>
58+
* <li>On pre-21 JDKs the mmap-fault {@code InternalError} is delivered
59+
* imprecisely ("a fault occurred in <i>a recent</i> unsafe memory access
60+
* operation in compiled Java code") and escapes a <i>reflective</i>
61+
* {@code Method.invoke} frame instead of being caught inside the scan --
62+
* so a reflection-based test spuriously fails on the shipping JDK 8/11/17
63+
* even though the direct-call production path catches it fine.</li>
64+
* </ul>
65+
* The unbacked tail is produced portably by truncating the file down (dropping
66+
* the tail blocks) and back up to the mapping size (leaving a sparse hole). A
67+
* hole-faulting filesystem (ZFS) then faults on the read exactly as in
68+
* production -- the case the fix must survive rather than fold the CRC through
69+
* the native, JNI-side {@code Crc32c} where a SIGBUS is uncatchable and aborts
70+
* the JVM. A hole-zero-filling filesystem (ext4) instead reads the hole back as
71+
* zeroes, which fails the frame CRC; either way recovery must stop at the same
72+
* boundary and recover the same frames.
6473
*/
6574
public class MmapSegmentRecoveryFaultTest {
6675

@@ -98,61 +107,117 @@ public void tearDown() {
98107
Files.remove(tmpDir);
99108
}
100109

110+
/**
111+
* Clean unbacked tail: a single frame ends exactly on a page boundary and
112+
* everything above it is a sparse hole. Recovery must keep the frame and
113+
* stop at the boundary, reporting no torn tail (an unwritten hole is not a
114+
* torn write).
115+
*/
101116
@Test
102-
public void testRecoveryScanTreatsUnbackedTailAsBoundary() throws Exception {
117+
public void testRecoveryKeepsFramesBeforeUnbackedTail() throws Exception {
103118
TestUtils.assertMemoryLeak(() -> {
104119
final String path = tmpDir + "/seg-unbacked-tail.sfa";
105-
// One frame sized so the segment's used region ends exactly on a
106-
// 4 KiB page boundary: HEADER_SIZE + FRAME_HEADER_SIZE + payload.
107-
// Truncating the backing to that boundary then leaves the NEXT
108-
// page entirely beyond EOF -- the deterministic mmap-fault case.
109-
final long pageBoundary = 4096L;
110-
final int payloadLen = (int) (pageBoundary - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE);
111-
long boundary;
112-
long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT);
113-
try {
114-
for (int i = 0; i < payloadLen; i++) {
115-
Unsafe.getUnsafe().putByte(buf + i, (byte) (i | 1)); // all non-zero
116-
}
117-
try (MmapSegment seg = MmapSegment.create(path, 7L, SEGMENT_BYTES)) {
118-
assertEquals(MmapSegment.HEADER_SIZE, seg.tryAppend(buf, payloadLen));
119-
boundary = seg.publishedOffset();
120-
}
121-
} finally {
122-
Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT);
120+
final long page = Files.PAGE_SIZE;
121+
// One frame sized so the used region ends exactly on a page
122+
// boundary: HEADER_SIZE + FRAME_HEADER_SIZE + payload == page.
123+
final int payloadLen = (int) (page - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE);
124+
125+
long boundary = writeSegment(path, 7L, new int[]{payloadLen});
126+
assertEquals("frame must fill exactly one page", page, boundary);
127+
// Drop the tail blocks, then re-extend logically so [page, SEGMENT_BYTES)
128+
// is an unbacked hole under the recovery mapping.
129+
punchSparseTail(path, page);
130+
131+
try (MmapSegment seg = MmapSegment.openExisting(path)) {
132+
assertEquals("the frame below the unbacked tail must be recovered", 1L, seg.frameCount());
133+
assertEquals("scan must stop at the unbacked-page boundary", page, seg.publishedOffset());
134+
assertEquals("an unwritten hole is not a torn write", 0L, seg.tornTailBytes());
123135
}
124-
assertEquals("frame must fill exactly one page", pageBoundary, boundary);
125-
126-
// Map the whole segment, then shrink the backing file under the
127-
// mapping so [boundary, SEGMENT_BYTES) is unbacked. Reads within
128-
// [0, boundary) stay valid; a read at/after `boundary` faults with
129-
// the same recoverable InternalError a sparse ZFS tail produces.
130-
int fd = Files.openRW(path);
131-
assertTrue("openRW failed", fd >= 0);
132-
long addr = Files.mmap(fd, SEGMENT_BYTES, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT);
133-
assertTrue("mmap failed", addr != Files.FAILED_MMAP_ADDRESS);
134-
try {
135-
assertTrue("truncate failed", Files.truncate(fd, boundary));
136-
137-
// scanFrames must keep the frame below the unbacked page and
138-
// return the boundary rather than propagating the fault.
139-
Method scanFrames = MmapSegment.class.getDeclaredMethod(
140-
"scanFrames", long.class, long.class);
141-
scanFrames.setAccessible(true);
142-
long lastGood = (Long) scanFrames.invoke(null, addr, SEGMENT_BYTES);
143-
assertEquals("scan must stop at the unbacked-page boundary", boundary, lastGood);
144-
145-
// detectTornTail probes the bail-out region -- itself unbacked
146-
// here -- and must report clean (0), not a fatal fault.
147-
Method detectTornTail = MmapSegment.class.getDeclaredMethod(
148-
"detectTornTail", long.class, long.class, long.class);
149-
detectTornTail.setAccessible(true);
150-
long torn = (Long) detectTornTail.invoke(null, addr, lastGood, SEGMENT_BYTES);
151-
assertEquals("unbacked tail is unwritten space, not a torn write", 0L, torn);
152-
} finally {
153-
Files.munmap(addr, SEGMENT_BYTES, MemoryTag.MMAP_DEFAULT);
154-
Files.close(fd);
136+
});
137+
}
138+
139+
/**
140+
* The harder case: a frame whose 8-byte header sits on a backed page but
141+
* whose payload reaches into the unbacked hole (a torn write leaves a real
142+
* positive {@code payloadLen} with the payload spanning the boundary). The
143+
* CRC fold therefore reads across the backed-to-unbacked edge. Recovery
144+
* must reject that frame and keep the one below it -- and, crucially, must
145+
* do so via {@code Unsafe} reads: the native, JNI-side {@code Crc32c} over
146+
* an unbacked page raises a SIGBUS that HotSpot cannot translate, aborting
147+
* the whole JVM (verified: an {@code hs_err} in
148+
* {@code Java_io_questdb_client_std_Crc32c_update}).
149+
*/
150+
@Test
151+
public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
152+
TestUtils.assertMemoryLeak(() -> {
153+
final String path = tmpDir + "/seg-unbacked-payload.sfa";
154+
final long page = Files.PAGE_SIZE;
155+
final long boundary = 2 * page;
156+
// Frame 2's header ends 8 bytes below the boundary (backed); its
157+
// payload starts 8 bytes below and runs a full page past -- across
158+
// the backed->unbacked edge.
159+
final long frame2Offset = boundary - 16;
160+
final int payloadLen2 = (int) page;
161+
final int payloadLen1 = (int) (frame2Offset - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE);
162+
163+
long used = writeSegment(path, 11L, new int[]{payloadLen1, payloadLen2});
164+
assertEquals("frame 2's header must end on the page boundary", boundary - 8, frame2Offset + MmapSegment.FRAME_HEADER_SIZE);
165+
assertTrue("frame 2 payload must reach past the boundary", used > boundary);
166+
punchSparseTail(path, boundary);
167+
168+
try (MmapSegment seg = MmapSegment.openExisting(path)) {
169+
assertEquals("only the frame below the unbacked payload is recoverable", 1L, seg.frameCount());
170+
assertEquals("scan must stop at the header-backed/payload-unbacked frame",
171+
frame2Offset, seg.publishedOffset());
172+
// Frame 2's header bytes are real (non-zero) and survive the
173+
// truncate, so the bail-out region is flagged as a torn tail.
174+
assertTrue("a torn write into the unbacked region must be flagged", seg.tornTailBytes() > 0);
155175
}
156176
});
157177
}
178+
179+
/**
180+
* Creates a segment at {@code path} and appends one frame per entry in
181+
* {@code payloadLens} (each filled with non-zero bytes so recovery can tell
182+
* written data from an unwritten/zeroed hole). Returns the used byte count
183+
* (the published offset after the last append).
184+
*/
185+
private static long writeSegment(String path, long baseSeq, int[] payloadLens) {
186+
int maxLen = 0;
187+
for (int len : payloadLens) {
188+
maxLen = Math.max(maxLen, len);
189+
}
190+
long buf = Unsafe.malloc(maxLen, MemoryTag.NATIVE_DEFAULT);
191+
try {
192+
for (int i = 0; i < maxLen; i++) {
193+
Unsafe.getUnsafe().putByte(buf + i, (byte) (i | 1)); // all non-zero
194+
}
195+
try (MmapSegment seg = MmapSegment.create(path, baseSeq, SEGMENT_BYTES)) {
196+
for (int len : payloadLens) {
197+
assertTrue("append must fit", seg.tryAppend(buf, len) >= 0);
198+
}
199+
return seg.publishedOffset();
200+
}
201+
} finally {
202+
Unsafe.free(buf, maxLen, MemoryTag.NATIVE_DEFAULT);
203+
}
204+
}
205+
206+
/**
207+
* Turns {@code [keepBytes, SEGMENT_BYTES)} of the file into an unbacked
208+
* sparse hole: truncate down to {@code keepBytes} (frees the tail blocks),
209+
* then back up to {@code SEGMENT_BYTES} (re-extends the logical size without
210+
* allocating blocks). Recovery maps the full stat length, so the hole is
211+
* inside the mapping -- reads of it fault on ZFS and zero-fill on ext4.
212+
*/
213+
private static void punchSparseTail(String path, long keepBytes) {
214+
int fd = Files.openRW(path);
215+
assertTrue("openRW failed", fd >= 0);
216+
try {
217+
assertTrue("truncate down failed", Files.truncate(fd, keepBytes));
218+
assertTrue("truncate up failed", Files.truncate(fd, SEGMENT_BYTES));
219+
} finally {
220+
Files.close(fd);
221+
}
222+
}
158223
}

0 commit comments

Comments
 (0)