From 104bc02798d4a2845dbe2f836f9000a57fd74d3a Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 8 Jul 2026 17:43:02 +0300 Subject: [PATCH 1/7] Survive an unbacked mapped page during segment recovery MmapSegment.openExisting maps a recovered .sfa to its stat length and scanFrames/detectTornTail read the mapping directly. When a prior session left a sparse segment tail -- a truncate-based pre-allocation that never materialized the tail blocks, as happens on ZFS -- a read of an unbacked page raises the JVM's recoverable InternalError ("a fault occurred in an unsafe memory access operation"), a translated SIGBUS. That InternalError is not a MmapSegmentException, so SegmentRing.open Existing's per-file skip did not catch it: it propagated to the outer catch, aborted recovery of the whole slot, and surfaced (via a drainer or a slot-lock probe) as a spurious "unsafe memory access" failure on ZFS CI runners. scanFrames and detectTornTail now catch the mmap access fault and treat the unreadable region exactly like unwritten space or a torn tail: the boundary of recoverable data. Every frame verified below the fault is kept; the slot recovers instead of failing. isMmapAccessFault matches the JVM's stable message so a genuine VirtualMachineError (real OOM, StackOverflow) is never swallowed. MmapSegmentRecoveryFaultTest reproduces the fault deterministically on any filesystem: it maps a valid one-frame segment, then truncates the backing under the still-larger mapping so the tail page is beyond EOF (the one case POSIX mmap always faults on), and asserts the scan stops at that page and keeps the frame below it. The test errors with the exact InternalError before the fix and passes after. Co-Authored-By: Claude Opus 4.8 --- .../qwp/client/sf/cursor/MmapSegment.java | 87 ++++++++-- .../cursor/MmapSegmentRecoveryFaultTest.java | 158 ++++++++++++++++++ 2 files changed, 228 insertions(+), 17 deletions(-) create mode 100644 core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index 32f5e5cc..13f65d86 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -506,6 +506,27 @@ public long tornTailBytes() { return tornTailBytes; } + /** + * True when {@code t} is the JVM's recoverable signal for a fault while + * accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot + * translates into {@code InternalError("a fault occurred in an unsafe + * memory access operation")} instead of aborting the process. It surfaces + * when a mapped page is not backed by real file blocks: a sparse + * {@code .sfa} tail on a filesystem whose pre-allocation leaves holes + * (e.g. ZFS, where a truncate-based {@code allocate} does not materialize + * blocks), or a file truncated under the mapping. Recovery treats this as + * an I/O boundary -- the same way MappedByteBuffer readers do -- not a + * fatal VM error. Matches on the JVM's stable message so a genuine + * VirtualMachineError (real OOM, StackOverflow) is never swallowed. + */ + private static boolean isMmapAccessFault(Throwable t) { + if (!(t instanceof InternalError)) { + return false; + } + String msg = t.getMessage(); + return msg != null && msg.contains("a fault occurred in an unsafe memory access"); + } + /** * Forward scan that returns the offset just past the last frame whose * CRC verifies. A torn-tail frame (declared length runs past EOF, or @@ -515,22 +536,43 @@ public long tornTailBytes() { */ private static long scanFrames(long addr, long fileSize) { long pos = HEADER_SIZE; - while (pos + FRAME_HEADER_SIZE <= fileSize) { - int crcRead = Unsafe.getUnsafe().getInt(addr + pos); - int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4); - // Defensive: a corrupt length field could be enormous or negative, - // both of which would otherwise overrun the mapping. - if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) { - return pos; - } - int crcCalc = Crc32c.update(Crc32c.INIT, addr + pos + 4, 4); - if (payloadLen > 0) { - crcCalc = Crc32c.update(crcCalc, addr + pos + FRAME_HEADER_SIZE, payloadLen); + try { + while (pos + FRAME_HEADER_SIZE <= fileSize) { + int crcRead = Unsafe.getUnsafe().getInt(addr + pos); + int payloadLen = Unsafe.getUnsafe().getInt(addr + pos + 4); + // Defensive: a corrupt length field could be enormous or negative, + // both of which would otherwise overrun the mapping. + if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) { + return pos; + } + int crcCalc = Crc32c.update(Crc32c.INIT, addr + pos + 4, 4); + if (payloadLen > 0) { + crcCalc = Crc32c.update(crcCalc, addr + pos + FRAME_HEADER_SIZE, payloadLen); + } + if (crcCalc != crcRead) { + return pos; + } + pos += FRAME_HEADER_SIZE + payloadLen; } - if (crcCalc != crcRead) { - return pos; + } catch (InternalError e) { + // The read at `pos` hit a mapped page that is not backed by real + // file blocks: the JVM translates the underlying SIGBUS into a + // recoverable InternalError instead of aborting the process. This + // happens when a prior session left a sparse segment tail (a + // truncate-based pre-allocation that does not materialize blocks, + // as on ZFS) or the file was truncated under the mapping. Every + // frame below `pos` already verified; treat the unreadable region + // exactly like unwritten space or a torn tail -- the boundary of + // recoverable data -- rather than letting the error abort recovery + // of the whole slot. Anything that is not the documented mmap + // access fault is a genuine VM error, so rethrow it. + if (!isMmapAccessFault(e)) { + throw e; } - pos += FRAME_HEADER_SIZE + payloadLen; + LOG.warn("SF segment recovery: unreadable mapped page at offset {} (file size {}); " + + "treating it as the end of recoverable data. Expected when a prior " + + "session left a sparse segment tail; investigate disk health if it recurs.", + pos, fileSize); } return pos; } @@ -553,10 +595,21 @@ private static long detectTornTail(long addr, long lastGood, long fileSize) { return 0L; } long probe = Math.min(FRAME_HEADER_SIZE, fileSize - lastGood); - for (long i = 0; i < probe; i++) { - if (Unsafe.getUnsafe().getByte(addr + lastGood + i) != 0) { - return fileSize - lastGood; + try { + for (long i = 0; i < probe; i++) { + if (Unsafe.getUnsafe().getByte(addr + lastGood + i) != 0) { + return fileSize - lastGood; + } + } + } catch (InternalError e) { + // The bail-out region is an unbacked (sparse) mapped page -- see + // scanFrames for the mechanism. An unbacked hole was never written, + // so it is clean unwritten space, not a torn write. Rethrow any + // error that is not the recoverable mmap access fault. + if (!isMmapAccessFault(e)) { + throw e; } + return 0L; } return 0L; } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java new file mode 100644 index 00000000..e59930f2 --- /dev/null +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -0,0 +1,158 @@ +/******************************************************************************* + * ___ _ ____ ____ + * / _ \ _ _ ___ ___| |_| _ \| __ ) + * | | | | | | |/ _ \/ __| __| | | | _ \ + * | |_| | |_| | __/\__ \ |_| |_| | |_) | + * \__\_\\__,_|\___||___/\__|____/|____/ + * + * Copyright (c) 2014-2019 Appsicle + * Copyright (c) 2019-2026 QuestDB + * + * Licensed 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 io.questdb.client.test.cutlass.qwp.client.sf.cursor; + +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.std.Files; +import io.questdb.client.std.MemoryTag; +import io.questdb.client.std.Unsafe; +import io.questdb.client.test.tools.TestUtils; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.lang.reflect.Method; +import java.nio.file.Paths; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}. + *

+ * {@code openExisting} maps a recovered {@code .sfa} to its stat length and + * {@code scanFrames} / {@code detectTornTail} read the mapping directly. When a + * prior session left a sparse segment tail -- a truncate-based pre-allocation + * that never materialized the tail blocks, as happens on ZFS -- a read of an + * unbacked page raises the JVM's recoverable + * {@code InternalError("a fault occurred in an unsafe memory access + * operation")} (a translated SIGBUS). That error is NOT a + * {@code MmapSegmentException}, so {@code SegmentRing.openExisting}'s per-file + * skip did not catch it: it aborted recovery of the whole slot, which surfaced + * (via a drainer/probe) as a spurious "unsafe memory access" failure on ZFS + * CI runners. + *

+ * The fault only reproduces on a real filesystem whose mmap reads of unwritten + * regions fault instead of zero-filling (ZFS), so this test induces the very + * same JVM-recoverable fault deterministically on any filesystem: it maps a + * valid segment file, then truncates the backing file under the still-larger + * mapping so the tail page is genuinely beyond EOF (the one case POSIX mmap + * always faults on). The scan must then stop at that page and keep every frame + * below it, exactly as it treats a torn tail. + */ +public class MmapSegmentRecoveryFaultTest { + + private static final long SEGMENT_BYTES = 1L << 20; + + private String tmpDir; + + @Before + public void setUp() { + tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), + "qdb-mmap-recover-" + System.nanoTime()).toString(); + assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); + } + + @After + public void tearDown() { + if (tmpDir == null) { + return; + } + long find = Files.findFirst(tmpDir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + Files.remove(tmpDir + "/" + name); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(tmpDir); + } + + @Test + public void testRecoveryScanTreatsUnbackedTailAsBoundary() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final String path = tmpDir + "/seg-unbacked-tail.sfa"; + // One frame sized so the segment's used region ends exactly on a + // 4 KiB page boundary: HEADER_SIZE + FRAME_HEADER_SIZE + payload. + // Truncating the backing to that boundary then leaves the NEXT + // page entirely beyond EOF -- the deterministic mmap-fault case. + final long pageBoundary = 4096L; + final int payloadLen = (int) (pageBoundary - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); + long boundary; + long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); + try { + for (int i = 0; i < payloadLen; i++) { + Unsafe.getUnsafe().putByte(buf + i, (byte) (i | 1)); // all non-zero + } + try (MmapSegment seg = MmapSegment.create(path, 7L, SEGMENT_BYTES)) { + assertEquals(MmapSegment.HEADER_SIZE, seg.tryAppend(buf, payloadLen)); + boundary = seg.publishedOffset(); + } + } finally { + Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); + } + assertEquals("frame must fill exactly one page", pageBoundary, boundary); + + // Map the whole segment, then shrink the backing file under the + // mapping so [boundary, SEGMENT_BYTES) is unbacked. Reads within + // [0, boundary) stay valid; a read at/after `boundary` faults with + // the same recoverable InternalError a sparse ZFS tail produces. + int fd = Files.openRW(path); + assertTrue("openRW failed", fd >= 0); + long addr = Files.mmap(fd, SEGMENT_BYTES, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT); + assertTrue("mmap failed", addr != Files.FAILED_MMAP_ADDRESS); + try { + assertTrue("truncate failed", Files.truncate(fd, boundary)); + + // scanFrames must keep the frame below the unbacked page and + // return the boundary rather than propagating the fault. + Method scanFrames = MmapSegment.class.getDeclaredMethod( + "scanFrames", long.class, long.class); + scanFrames.setAccessible(true); + long lastGood = (Long) scanFrames.invoke(null, addr, SEGMENT_BYTES); + assertEquals("scan must stop at the unbacked-page boundary", boundary, lastGood); + + // detectTornTail probes the bail-out region -- itself unbacked + // here -- and must report clean (0), not a fatal fault. + Method detectTornTail = MmapSegment.class.getDeclaredMethod( + "detectTornTail", long.class, long.class, long.class); + detectTornTail.setAccessible(true); + long torn = (Long) detectTornTail.invoke(null, addr, lastGood, SEGMENT_BYTES); + assertEquals("unbacked tail is unwritten space, not a torn write", 0L, torn); + } finally { + Files.munmap(addr, SEGMENT_BYTES, MemoryTag.MMAP_DEFAULT); + Files.close(fd); + } + }); + } +} From 779dfd02018762a7698bebd0eab070a691d8d0e5 Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 8 Jul 2026 19:27:51 +0300 Subject: [PATCH 2/7] 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 --- .../qwp/client/sf/cursor/MmapSegment.java | 86 ++++++-- .../cursor/MmapSegmentRecoveryFaultTest.java | 201 ++++++++++++------ 2 files changed, 205 insertions(+), 82 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index 13f65d86..24dac356 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -64,6 +64,7 @@ public final class MmapSegment implements QuietCloseable { public static final int FRAME_HEADER_SIZE = 8; // u32 crc + u32 payloadLen public static final int HEADER_SIZE = 24; public static final byte VERSION = 1; + private static final int[] CRC32C_TABLE = buildCrc32cTable(); private static final Logger LOG = LoggerFactory.getLogger(MmapSegment.class); private final String path; @@ -509,22 +510,30 @@ public long tornTailBytes() { /** * True when {@code t} is the JVM's recoverable signal for a fault while * accessing a memory-mapped region -- a SIGBUS/SIGSEGV that HotSpot - * translates into {@code InternalError("a fault occurred in an unsafe - * memory access operation")} instead of aborting the process. It surfaces - * when a mapped page is not backed by real file blocks: a sparse - * {@code .sfa} tail on a filesystem whose pre-allocation leaves holes - * (e.g. ZFS, where a truncate-based {@code allocate} does not materialize - * blocks), or a file truncated under the mapping. Recovery treats this as - * an I/O boundary -- the same way MappedByteBuffer readers do -- not a - * fatal VM error. Matches on the JVM's stable message so a genuine - * VirtualMachineError (real OOM, StackOverflow) is never swallowed. + * translates into an {@code InternalError} at an {@code Unsafe} intrinsic + * site instead of aborting the process. It surfaces when a mapped page is + * not backed by real file blocks: a sparse {@code .sfa} tail on a + * filesystem whose pre-allocation leaves holes (e.g. ZFS, where a + * truncate-based {@code allocate} does not materialize blocks), or a file + * truncated under the mapping. Recovery treats this as an I/O boundary -- + * the same way MappedByteBuffer readers do -- not a fatal VM error. + *

+ * The message is matched on the fragment {@code "unsafe memory access + * operation"}, which is common to both HotSpot wordings and NOT + * version-stable as a whole: pre-21 JDKs (including the shipping/CI JDK 8) + * emit {@code "a fault occurred in a recent unsafe memory access operation + * in compiled Java code"}, while JDK 21+ shortened it to {@code "a fault + * occurred in an unsafe memory access operation"}. Matching the shared + * fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while + * still being specific enough that a genuine VirtualMachineError (real OOM, + * StackOverflow) is never swallowed. */ private static boolean isMmapAccessFault(Throwable t) { if (!(t instanceof InternalError)) { return false; } String msg = t.getMessage(); - return msg != null && msg.contains("a fault occurred in an unsafe memory access"); + return msg != null && msg.contains("unsafe memory access operation"); } /** @@ -545,10 +554,26 @@ private static long scanFrames(long addr, long fileSize) { if (payloadLen < 0 || pos + FRAME_HEADER_SIZE + payloadLen > fileSize) { return pos; } - int crcCalc = Crc32c.update(Crc32c.INIT, addr + pos + 4, 4); - if (payloadLen > 0) { - crcCalc = Crc32c.update(crcCalc, addr + pos + FRAME_HEADER_SIZE, payloadLen); - } + // CRC over the contiguous (payloadLen, payload) pair, folded + // via Unsafe reads rather than the native Crc32c.update. + // Recovery maps to the file's stat length, but a page inside + // that range can be unbacked: a sparse pre-allocation tail (a + // truncate-based allocate that never materialized blocks, as on + // ZFS), or -- via a torn write, since tryAppend writes the + // length field before copying the payload -- a real positive + // payloadLen whose payload spans into an unwritten hole. A raw + // read of an unbacked page raises SIGBUS; HotSpot translates + // that into a catchable InternalError ONLY at an Unsafe + // intrinsic site, NEVER inside JNI native code, so a native + // Crc32c.update over such a page aborts the whole JVM (and, + // empirically on pre-21 JDKs, a preceding Unsafe pre-touch does + // not reliably fault first once an earlier native CRC ran in + // the same scan). Folding over Unsafe keeps every fault + // catchable -- handled below as the boundary of recoverable + // data; a page that instead reads back as zeroes just fails the + // CRC check and ends the scan. Recovery is cold, so the slower + // table CRC here is immaterial. + int crcCalc = crc32cRecovery(addr + pos + 4, 4L + payloadLen); if (crcCalc != crcRead) { return pos; } @@ -577,6 +602,39 @@ private static long scanFrames(long addr, long fileSize) { return pos; } + /** + * CRC-32C (Castagnoli) of {@code [addr, addr + len)} read through + * {@link Unsafe}, seeded like {@code Crc32c.update(Crc32c.INIT, addr, len)} + * and bit-identical to it (verified) -- but every byte load is an Unsafe + * intrinsic, so a fault on an unbacked mapped page is a catchable + * {@link InternalError} instead of the uncatchable JNI SIGBUS the native + * {@link Crc32c} would raise. Byte-at-a-time via a precomputed table + * ({@code ~0.5 GiB/s}); used only on the cold recovery scan, never on the + * append hot path (which stays on the native, hardware-friendly path). + */ + private static int crc32cRecovery(long addr, long len) { + int crc = ~Crc32c.INIT; + for (long i = 0; i < len; i++) { + crc = (crc >>> 8) ^ CRC32C_TABLE[(crc ^ Unsafe.getUnsafe().getByte(addr + i)) & 0xFF]; + } + return ~crc; + } + + // Standard reflected CRC-32C byte table (polynomial 0x82F63B78), matching + // crc32c_table[0] in the native crc32c.c. Computed at class init to avoid + // 256 hand-transcribed literals; drives crc32cRecovery. + private static int[] buildCrc32cTable() { + int[] table = new int[256]; + for (int n = 0; n < 256; n++) { + int c = n; + for (int k = 0; k < 8; k++) { + c = (c & 1) != 0 ? (0x82F63B78 ^ (c >>> 1)) : (c >>> 1); + } + table[n] = c; + } + return table; + } + /** * Distinguishes "torn tail" (writer attempted a write past the last valid * frame and failed — partial write, mid-stream corruption, bit rot) from diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java index e59930f2..e0bd51af 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -33,7 +33,6 @@ import org.junit.Before; import org.junit.Test; -import java.lang.reflect.Method; import java.nio.file.Paths; import static org.junit.Assert.assertEquals; @@ -42,25 +41,35 @@ /** * Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}. *

- * {@code openExisting} maps a recovered {@code .sfa} to its stat length and - * {@code scanFrames} / {@code detectTornTail} read the mapping directly. When a - * prior session left a sparse segment tail -- a truncate-based pre-allocation - * that never materialized the tail blocks, as happens on ZFS -- a read of an + * On recovery, {@link MmapSegment#openExisting} maps a persisted {@code .sfa} + * to its stat length and scans frames straight out of the mapping. When a prior + * session left a sparse segment tail -- a truncate-based pre-allocation that + * never materialized the tail blocks, as happens on ZFS -- a read of an * unbacked page raises the JVM's recoverable - * {@code InternalError("a fault occurred in an unsafe memory access - * operation")} (a translated SIGBUS). That error is NOT a - * {@code MmapSegmentException}, so {@code SegmentRing.openExisting}'s per-file - * skip did not catch it: it aborted recovery of the whole slot, which surfaced - * (via a drainer/probe) as a spurious "unsafe memory access" failure on ZFS - * CI runners. + * {@code InternalError("...unsafe memory access operation...")} (a translated + * SIGBUS). Recovery must treat that page as the boundary of recoverable data, + * keep every frame below it, and hand back a usable segment -- not let the + * error abort recovery of the whole slot (the reported ZFS-CI flake). *

- * The fault only reproduces on a real filesystem whose mmap reads of unwritten - * regions fault instead of zero-filling (ZFS), so this test induces the very - * same JVM-recoverable fault deterministically on any filesystem: it maps a - * valid segment file, then truncates the backing file under the still-larger - * mapping so the tail page is genuinely beyond EOF (the one case POSIX mmap - * always faults on). The scan must then stop at that page and keep every frame - * below it, exactly as it treats a torn tail. + * These tests drive the production entry point ({@code openExisting}), + * not the private scan methods via reflection. That matters for two reasons: + *

+ * The unbacked tail is produced portably by truncating the file down (dropping + * the tail blocks) and back up to the mapping size (leaving a sparse hole). A + * hole-faulting filesystem (ZFS) then faults on the read exactly as in + * production -- the case the fix must survive rather than fold the CRC through + * the native, JNI-side {@code Crc32c} where a SIGBUS is uncatchable and aborts + * the JVM. A hole-zero-filling filesystem (ext4) instead reads the hole back as + * zeroes, which fails the frame CRC; either way recovery must stop at the same + * boundary and recover the same frames. */ public class MmapSegmentRecoveryFaultTest { @@ -98,61 +107,117 @@ public void tearDown() { Files.remove(tmpDir); } + /** + * Clean unbacked tail: a single frame ends exactly on a page boundary and + * everything above it is a sparse hole. Recovery must keep the frame and + * stop at the boundary, reporting no torn tail (an unwritten hole is not a + * torn write). + */ @Test - public void testRecoveryScanTreatsUnbackedTailAsBoundary() throws Exception { + public void testRecoveryKeepsFramesBeforeUnbackedTail() throws Exception { TestUtils.assertMemoryLeak(() -> { final String path = tmpDir + "/seg-unbacked-tail.sfa"; - // One frame sized so the segment's used region ends exactly on a - // 4 KiB page boundary: HEADER_SIZE + FRAME_HEADER_SIZE + payload. - // Truncating the backing to that boundary then leaves the NEXT - // page entirely beyond EOF -- the deterministic mmap-fault case. - final long pageBoundary = 4096L; - final int payloadLen = (int) (pageBoundary - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); - long boundary; - long buf = Unsafe.malloc(payloadLen, MemoryTag.NATIVE_DEFAULT); - try { - for (int i = 0; i < payloadLen; i++) { - Unsafe.getUnsafe().putByte(buf + i, (byte) (i | 1)); // all non-zero - } - try (MmapSegment seg = MmapSegment.create(path, 7L, SEGMENT_BYTES)) { - assertEquals(MmapSegment.HEADER_SIZE, seg.tryAppend(buf, payloadLen)); - boundary = seg.publishedOffset(); - } - } finally { - Unsafe.free(buf, payloadLen, MemoryTag.NATIVE_DEFAULT); + final long page = Files.PAGE_SIZE; + // One frame sized so the used region ends exactly on a page + // boundary: HEADER_SIZE + FRAME_HEADER_SIZE + payload == page. + final int payloadLen = (int) (page - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); + + long boundary = writeSegment(path, 7L, new int[]{payloadLen}); + assertEquals("frame must fill exactly one page", page, boundary); + // Drop the tail blocks, then re-extend logically so [page, SEGMENT_BYTES) + // is an unbacked hole under the recovery mapping. + punchSparseTail(path, page); + + try (MmapSegment seg = MmapSegment.openExisting(path)) { + assertEquals("the frame below the unbacked tail must be recovered", 1L, seg.frameCount()); + assertEquals("scan must stop at the unbacked-page boundary", page, seg.publishedOffset()); + assertEquals("an unwritten hole is not a torn write", 0L, seg.tornTailBytes()); } - assertEquals("frame must fill exactly one page", pageBoundary, boundary); - - // Map the whole segment, then shrink the backing file under the - // mapping so [boundary, SEGMENT_BYTES) is unbacked. Reads within - // [0, boundary) stay valid; a read at/after `boundary` faults with - // the same recoverable InternalError a sparse ZFS tail produces. - int fd = Files.openRW(path); - assertTrue("openRW failed", fd >= 0); - long addr = Files.mmap(fd, SEGMENT_BYTES, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT); - assertTrue("mmap failed", addr != Files.FAILED_MMAP_ADDRESS); - try { - assertTrue("truncate failed", Files.truncate(fd, boundary)); - - // scanFrames must keep the frame below the unbacked page and - // return the boundary rather than propagating the fault. - Method scanFrames = MmapSegment.class.getDeclaredMethod( - "scanFrames", long.class, long.class); - scanFrames.setAccessible(true); - long lastGood = (Long) scanFrames.invoke(null, addr, SEGMENT_BYTES); - assertEquals("scan must stop at the unbacked-page boundary", boundary, lastGood); - - // detectTornTail probes the bail-out region -- itself unbacked - // here -- and must report clean (0), not a fatal fault. - Method detectTornTail = MmapSegment.class.getDeclaredMethod( - "detectTornTail", long.class, long.class, long.class); - detectTornTail.setAccessible(true); - long torn = (Long) detectTornTail.invoke(null, addr, lastGood, SEGMENT_BYTES); - assertEquals("unbacked tail is unwritten space, not a torn write", 0L, torn); - } finally { - Files.munmap(addr, SEGMENT_BYTES, MemoryTag.MMAP_DEFAULT); - Files.close(fd); + }); + } + + /** + * The harder case: a frame whose 8-byte header sits on a backed page but + * whose payload reaches into the unbacked hole (a torn write leaves a real + * positive {@code payloadLen} with the payload spanning the boundary). The + * CRC fold therefore reads across the backed-to-unbacked edge. Recovery + * must reject that frame and keep the one below it -- and, crucially, must + * do so via {@code Unsafe} reads: the native, JNI-side {@code Crc32c} over + * an unbacked page raises a SIGBUS that HotSpot cannot translate, aborting + * the whole JVM (verified: an {@code hs_err} in + * {@code Java_io_questdb_client_std_Crc32c_update}). + */ + @Test + public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final String path = tmpDir + "/seg-unbacked-payload.sfa"; + final long page = Files.PAGE_SIZE; + final long boundary = 2 * page; + // Frame 2's header ends 8 bytes below the boundary (backed); its + // payload starts 8 bytes below and runs a full page past -- across + // the backed->unbacked edge. + final long frame2Offset = boundary - 16; + final int payloadLen2 = (int) page; + final int payloadLen1 = (int) (frame2Offset - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); + + long used = writeSegment(path, 11L, new int[]{payloadLen1, payloadLen2}); + assertEquals("frame 2's header must end on the page boundary", boundary - 8, frame2Offset + MmapSegment.FRAME_HEADER_SIZE); + assertTrue("frame 2 payload must reach past the boundary", used > boundary); + punchSparseTail(path, boundary); + + try (MmapSegment seg = MmapSegment.openExisting(path)) { + assertEquals("only the frame below the unbacked payload is recoverable", 1L, seg.frameCount()); + assertEquals("scan must stop at the header-backed/payload-unbacked frame", + frame2Offset, seg.publishedOffset()); + // Frame 2's header bytes are real (non-zero) and survive the + // truncate, so the bail-out region is flagged as a torn tail. + assertTrue("a torn write into the unbacked region must be flagged", seg.tornTailBytes() > 0); } }); } + + /** + * Creates a segment at {@code path} and appends one frame per entry in + * {@code payloadLens} (each filled with non-zero bytes so recovery can tell + * written data from an unwritten/zeroed hole). Returns the used byte count + * (the published offset after the last append). + */ + private static long writeSegment(String path, long baseSeq, int[] payloadLens) { + int maxLen = 0; + for (int len : payloadLens) { + maxLen = Math.max(maxLen, len); + } + long buf = Unsafe.malloc(maxLen, MemoryTag.NATIVE_DEFAULT); + try { + for (int i = 0; i < maxLen; i++) { + Unsafe.getUnsafe().putByte(buf + i, (byte) (i | 1)); // all non-zero + } + try (MmapSegment seg = MmapSegment.create(path, baseSeq, SEGMENT_BYTES)) { + for (int len : payloadLens) { + assertTrue("append must fit", seg.tryAppend(buf, len) >= 0); + } + return seg.publishedOffset(); + } + } finally { + Unsafe.free(buf, maxLen, MemoryTag.NATIVE_DEFAULT); + } + } + + /** + * Turns {@code [keepBytes, SEGMENT_BYTES)} of the file into an unbacked + * sparse hole: truncate down to {@code keepBytes} (frees the tail blocks), + * then back up to {@code SEGMENT_BYTES} (re-extends the logical size without + * allocating blocks). Recovery maps the full stat length, so the hole is + * inside the mapping -- reads of it fault on ZFS and zero-fill on ext4. + */ + private static void punchSparseTail(String path, long keepBytes) { + int fd = Files.openRW(path); + assertTrue("openRW failed", fd >= 0); + try { + assertTrue("truncate down failed", Files.truncate(fd, keepBytes)); + assertTrue("truncate up failed", Files.truncate(fd, SEGMENT_BYTES)); + } finally { + Files.close(fd); + } + } } From 2721f39f2d6eab09c9f6a97bcea9f95a00aff21b Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 8 Jul 2026 19:37:26 +0300 Subject: [PATCH 3/7] fix(qwp): guard header reads in recovery; drop the sparse-tail-only WARN framing M1: openExisting reads magic/version/baseSeq before scanFrames with no guard, so an unbacked page 0 (unflushed header on a truncate-based-allocate FS after a crash, or a file truncated under the mapping) faults with an InternalError that SegmentRing's per-file catch (MmapSegmentException) does not catch -- it escapes to the outer catch (Throwable) and aborts recovery of every sibling segment. Convert an isMmapAccessFault into a MmapSegmentException in openExisting's catch so only that one .sfa is skipped. Adds a portable regression test. M2: the scanFrames in-mapping-fault WARN framed the outcome as "Expected when a prior session left a sparse segment tail", downplaying a possible mid-file media error. Reword it to state frames beyond the fault are discarded and that a bad sector is indistinguishable here, and document that tornTailBytes() reports 0 for an unbacked bail-out region by design. Co-Authored-By: Claude Opus 4.8 --- .../qwp/client/sf/cursor/MmapSegment.java | 32 ++++++++++++++++-- .../cursor/MmapSegmentRecoveryFaultTest.java | 33 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index 24dac356..e4b4e99c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -310,6 +310,23 @@ public static MmapSegment openExisting(String path) { Files.munmap(addr, fileSize, MemoryTag.MMAP_DEFAULT); } Files.close(fd); + // The header reads above (magic/version/baseSeq) run before + // scanFrames and are otherwise unguarded. An unbacked page 0 -- + // an unflushed header on a truncate-based-allocate filesystem + // after a crash (create() does not msync), or a file truncated + // under the mapping -- faults at the Unsafe intrinsic site as a + // catchable InternalError. Convert it to a MmapSegmentException so + // SegmentRing's per-file catch skips just this .sfa, instead of + // letting the raw InternalError escape to SegmentRing's outer catch + // and abort recovery of every sibling segment. scanFrames and + // detectTornTail already handle their own in-mapping faults; this + // covers the header block and any future reader placed ahead of the + // scan. + if (isMmapAccessFault(t)) { + throw new MmapSegmentException( + "unreadable mapped header page in " + path + + " (unbacked/sparse page 0): " + t.getMessage(), t); + } throw t; } } @@ -502,6 +519,13 @@ public long frameCount() { * fresh segments, memory-backed segments, and cleanly partially-filled * recovered segments. Operators / tests can read this to tell silent * truncation (corruption) from a normal partial fill (no incident). + *

+ * One case this does NOT count: when the scan stops because the bail-out + * region is itself an unbacked mapped page (an in-mapping fault, not a + * backed torn header), that region cannot be probed, so this returns + * {@code 0} even though frames may have been discarded. That outcome is + * surfaced by the {@code WARN} in {@link #scanFrames} instead -- see it for + * the benign-tail-vs-media-error caveat. */ public long tornTailBytes() { return tornTailBytes; @@ -595,8 +619,12 @@ private static long scanFrames(long addr, long fileSize) { throw e; } LOG.warn("SF segment recovery: unreadable mapped page at offset {} (file size {}); " - + "treating it as the end of recoverable data. Expected when a prior " - + "session left a sparse segment tail; investigate disk health if it recurs.", + + "treating it as the end of recoverable data -- any frames beyond this " + + "offset are discarded. The usual cause is a benign sparse pre-allocation " + + "tail (e.g. a truncate-based allocate on ZFS) left by a prior session, " + + "but a mid-file media error (bad sector) is indistinguishable here; " + + "check disk health if this segment was expected to be fully written or " + + "if this recurs.", pos, fileSize); } return pos; diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java index e0bd51af..6bf8dac6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -25,6 +25,7 @@ package io.questdb.client.test.cutlass.qwp.client.sf.cursor; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; +import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException; import io.questdb.client.std.Files; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Unsafe; @@ -37,6 +38,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; /** * Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}. @@ -176,6 +178,37 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception { }); } + /** + * M1 regression: the header block (magic/version/baseSeq) is read before + * {@code scanFrames}, so an unbacked page 0 faults ahead of the guarded + * scan. {@link MmapSegment#openExisting} must surface that as a + * {@link MmapSegmentException} -- the per-file signal {@code SegmentRing} + * catches to skip just this {@code .sfa} -- and never let the raw + * {@code InternalError} escape and abort recovery of every sibling segment. + *

+ * Portable across filesystems: on a hole-faulting FS (ZFS) the fault is + * converted to a {@code MmapSegmentException} in {@code openExisting}'s + * catch; on a hole-zero-filling FS (ext4) page 0 reads back as zeroes, so + * the magic check fails and throws {@code MmapSegmentException} directly. + * Either way the file is skippable, not fatal. + */ + @Test + public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final String path = tmpDir + "/seg-unbacked-header.sfa"; + writeSegment(path, 3L, new int[]{64}); + // Punch the whole file into a hole -- page 0 (the header) included. + punchSparseTail(path, 0L); + try { + MmapSegment.openExisting(path).close(); + fail("expected MmapSegmentException for an unbacked header page"); + } catch (MmapSegmentException expected) { + // ok -- SegmentRing's per-file catch skips just this file + // instead of aborting recovery of the whole slot. + } + }); + } + /** * Creates a segment at {@code path} and appends one frame per entry in * {@code payloadLens} (each filled with non-zero bytes so recovery can tell From 16df6f6a9682d7afb501291359f0eedcf6316ccc Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 8 Jul 2026 19:57:23 +0300 Subject: [PATCH 4/7] test(qwp): dedupe SF tmp-dir fixtures; name the sparse-tail cause in the FSN-gap error Follow-up on the PR #64 review minors. m3: MmapSegmentTest and MmapSegmentRecoveryFaultTest carried verbatim copies of the native tmp-dir setUp/tearDown (~28 lines each). Hoist them into TestUtils.createTmpDir / removeTmpDir so both classes and any future SF cursor test share one implementation. m5: the FSN-gap MmapSegmentException fired on a sealed segment that under-recovered (a sparse/unbacked or media-truncated tail), but its message and comment blamed only "partial-write/manual-deletion". Name the truncated-tail cause and ask operators to check disk health, matching the diagnostics-honesty wording already added to the scanFrames WARN. Co-Authored-By: Claude Opus 4.8 --- .../qwp/client/sf/cursor/SegmentRing.java | 13 ++++-- .../cursor/MmapSegmentRecoveryFaultTest.java | 26 +---------- .../qwp/client/sf/cursor/MmapSegmentTest.java | 26 +---------- .../questdb/client/test/tools/TestUtils.java | 43 +++++++++++++++++++ 4 files changed, 57 insertions(+), 51 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java index 26040a0c..c8516c4c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentRing.java @@ -265,8 +265,12 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) { // pivot would degrade back to O(N²) on exactly that common case. sortByBaseSeq(opened, 0, opened.size()); // Sanity: the recovered segments must form a contiguous FSN range. - // Detect gaps so a partial-write/manual-deletion mishap doesn't - // silently produce duplicate or missing FSNs after recovery. + // Detect gaps so they don't silently produce duplicate or missing + // FSNs after recovery. A gap means a segment went missing (a + // manual deletion) or a sealed segment under-recovered -- its tail + // was cut short by a sparse/unbacked page or a mid-file media error + // (bad sector), the same class of fault scanFrames tolerates on the + // active segment but which corrupts the range on a sealed one. for (int i = 1, n = opened.size(); i < n; i++) { MmapSegment prev = opened.get(i - 1); MmapSegment curr = opened.get(i); @@ -276,7 +280,10 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) { "FSN gap in recovered segments: prev baseSeq=" + prev.baseSeq() + " frameCount=" + prev.frameCount() + " expected next baseSeq=" + expected - + " but got " + curr.baseSeq()); + + " but got " + curr.baseSeq() + + " -- a segment was deleted, or a sealed segment's tail was" + + " truncated (sparse/unbacked page or disk media error);" + + " check disk health"); } } // The newest segment becomes the active. Even if it's full, that's OK: diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java index 6bf8dac6..7b0db7f0 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -34,8 +34,6 @@ import org.junit.Before; import org.junit.Test; -import java.nio.file.Paths; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -81,32 +79,12 @@ public class MmapSegmentRecoveryFaultTest { @Before public void setUp() { - tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), - "qdb-mmap-recover-" + System.nanoTime()).toString(); - assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); + tmpDir = TestUtils.createTmpDir("qdb-mmap-recover-"); } @After public void tearDown() { - if (tmpDir == null) { - return; - } - long find = Files.findFirst(tmpDir); - if (find > 0) { - try { - int rc = 1; - while (rc > 0) { - String name = Files.utf8ToString(Files.findName(find)); - if (name != null && !".".equals(name) && !"..".equals(name)) { - Files.remove(tmpDir + "/" + name); - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(tmpDir); + TestUtils.removeTmpDir(tmpDir); } /** diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java index 42da6526..164fce12 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java @@ -35,8 +35,6 @@ import org.junit.Before; import org.junit.Test; -import java.nio.file.Paths; - import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; @@ -49,32 +47,12 @@ public class MmapSegmentTest { @Before public void setUp() { - tmpDir = Paths.get(System.getProperty("java.io.tmpdir"), - "qdb-mmap-seg-" + System.nanoTime()).toString(); - assertEquals(0, Files.mkdir(tmpDir, Files.DIR_MODE_DEFAULT)); + tmpDir = TestUtils.createTmpDir("qdb-mmap-seg-"); } @After public void tearDown() { - if (tmpDir == null) { - return; - } - long find = Files.findFirst(tmpDir); - if (find > 0) { - try { - int rc = 1; - while (rc > 0) { - String name = Files.utf8ToString(Files.findName(find)); - if (name != null && !".".equals(name) && !"..".equals(name)) { - Files.remove(tmpDir + "/" + name); - } - rc = Files.findNext(find); - } - } finally { - Files.findClose(find); - } - } - Files.remove(tmpDir); + TestUtils.removeTmpDir(tmpDir); } @Test diff --git a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java index 270311cf..7572880b 100644 --- a/core/src/test/java/io/questdb/client/test/tools/TestUtils.java +++ b/core/src/test/java/io/questdb/client/test/tools/TestUtils.java @@ -24,6 +24,7 @@ package io.questdb.client.test.tools; +import io.questdb.client.std.Files; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.QuietCloseable; import io.questdb.client.std.Rnd; @@ -139,6 +140,18 @@ public static long beHexToLong(String hex) { return Long.parseLong(reverseBeHex(hex), 16); } + /** + * Creates a unique temp directory under {@code java.io.tmpdir}, named + * {@code prefix + }, and returns its path. Pair with + * {@link #removeTmpDir(String)} in {@code tearDown}. + */ + public static String createTmpDir(String prefix) { + String dir = Paths.get(System.getProperty("java.io.tmpdir"), + prefix + System.nanoTime()).toString(); + Assert.assertEquals(0, Files.mkdir(dir, Files.DIR_MODE_DEFAULT)); + return dir; + } + @NotNull public static Rnd generateRandom(Logger log) { return generateRandom(log, System.nanoTime(), System.currentTimeMillis()); @@ -172,6 +185,36 @@ public static String ipv4ToString(int ip) { return ((ip >> 24) & 0xff) + "." + ((ip >> 16) & 0xff) + "." + ((ip >> 8) & 0xff) + "." + (ip & 0xff); } + /** + * Flat (non-recursive) cleanup for a directory created by + * {@link #createTmpDir(String)}: removes every entry in {@code tmpDir} + * (the SF cursor tests only write flat {@code .sfa}/{@code .corrupt} + * files) and then the directory itself. A {@code null} argument is a + * no-op, so it is safe to call from {@code tearDown} before {@code setUp} + * ran. + */ + public static void removeTmpDir(String tmpDir) { + if (tmpDir == null) { + return; + } + long find = Files.findFirst(tmpDir); + if (find > 0) { + try { + int rc = 1; + while (rc > 0) { + String name = Files.utf8ToString(Files.findName(find)); + if (name != null && !".".equals(name) && !"..".equals(name)) { + Files.remove(tmpDir + "/" + name); + } + rc = Files.findNext(find); + } + } finally { + Files.findClose(find); + } + } + Files.remove(tmpDir); + } + /** * Java 8 stand-in for {@code String.repeat(int)} (added in Java 11). */ From 4f78df2c03f49bf6a471023c3b4704087cfaed80 Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Wed, 8 Jul 2026 21:56:16 +0300 Subject: [PATCH 5/7] test(qwp): portably regression-guard recovery's mmap-fault path (#64 M1) The three MmapSegmentRecoveryFaultTest cases only fault on ZFS: on ext4/xfs a within-EOF sparse hole zero-fills, so the scan stops via the CRC-mismatch / bad-magic branch and every test stays green even with the recovery mmap-fault guard fully reverted -- zero fail-on-revert protection on the primary CI runners (PR #64 review, item M1). Proven by reverting both production files to the pre-branch base: 3/3 still passed on ext4. Add a portable seam and two tests that fault deterministically on any filesystem. A read of a mapped page beyond the file's real EOF raises SIGBUS everywhere (POSIX), which HotSpot translates to a catchable InternalError at an Unsafe intrinsic site -- the same fault an unbacked ZFS page raises. openExisting gains a FilesFacade overload (mirroring create) whose file length flows through the facade; a test facade reports a length larger than the real (truncated-down) file so the mapping extends past EOF. - FilesFacade.length(String) + DefaultFilesFacade delegate; openExisting(String) now delegates to openExisting(FilesFacade, String), which also routes openRW and close through the facade. - testScanFaultOnMapPastEofIsHandledAnyFilesystem and testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem fault on ext4 and fail on revert (verified: raw InternalError escapes when the guard is neutralized, while the three sparse-hole tests stay green -- exactly the M1 gap). The scan test accepts both handled outcomes: under the interpreter/C1 the fault is caught in scanFrames (graceful partial recovery), but once C2 inlines scanFrames into openExisting the async unsafe-access InternalError is delivered to openExisting's outer catch instead, converting the file to a skippable MmapSegmentException. Both are safe (no JVM abort, no raw error into recovery); a revert lets a raw InternalError through and fails the test either way. Co-Authored-By: Claude Opus 4.8 --- .../qwp/client/sf/cursor/MmapSegment.java | 26 +- .../client/std/DefaultFilesFacade.java | 5 + .../io/questdb/client/std/FilesFacade.java | 16 ++ .../cursor/MmapSegmentRecoveryFaultTest.java | 266 ++++++++++++++++++ .../qwp/client/sf/cursor/MmapSegmentTest.java | 5 + 5 files changed, 315 insertions(+), 3 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index e4b4e99c..c2b6792c 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -258,11 +258,31 @@ public static MmapSegment createInMemory(long baseSeq, long sizeBytes) { * last valid frame) do not log and report {@code 0}. */ public static MmapSegment openExisting(String path) { - long fileSize = Files.length(path); + return openExisting(FilesFacade.INSTANCE, path); + } + + /** + * Facade-aware variant of {@link #openExisting(String)} that takes the file + * length (and open/close) through a {@link FilesFacade} instead of straight + * off {@link Files}. Production uses {@link FilesFacade#INSTANCE}; the seam + * exists so recovery's mmap-fault guard can be regression-tested on any + * filesystem. + *

+ * The mapping is sized to {@code ff.length(path)} and every recovery read + * runs straight out of it, so a facade that reports a length larger + * than the real file makes the mapping extend past end-of-file. A read of a + * page beyond real EOF raises SIGBUS on every filesystem — the same + * fault an unbacked/sparse page raises on ZFS, but reproduced + * deterministically on ext4/xfs, where a within-EOF hole would instead + * zero-fill and never exercise the guard. See + * {@link FilesFacade#length(String)}. + */ + public static MmapSegment openExisting(FilesFacade ff, String path) { + long fileSize = ff.length(path); if (fileSize < HEADER_SIZE) { throw new MmapSegmentException("file shorter than header: " + path + " size=" + fileSize); } - int fd = Files.openRW(path); + int fd = ff.openRW(path); if (fd < 0) { throw new MmapSegmentException("openRW failed for " + path); } @@ -309,7 +329,7 @@ public static MmapSegment openExisting(String path) { if (addr != Files.FAILED_MMAP_ADDRESS) { Files.munmap(addr, fileSize, MemoryTag.MMAP_DEFAULT); } - Files.close(fd); + ff.close(fd); // The header reads above (magic/version/baseSeq) run before // scanFrames and are otherwise unguarded. An unbacked page 0 -- // an unflushed header on a truncate-based-allocate filesystem diff --git a/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java b/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java index d9b4b614..dca93e84 100644 --- a/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java +++ b/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java @@ -91,6 +91,11 @@ public long length(int fd) { return Files.length(fd); } + @Override + public long length(String path) { + return Files.length(path); + } + @Override public int lock(int fd) { return Files.lock(fd); diff --git a/core/src/main/java/io/questdb/client/std/FilesFacade.java b/core/src/main/java/io/questdb/client/std/FilesFacade.java index e6d90f95..1b408cf4 100644 --- a/core/src/main/java/io/questdb/client/std/FilesFacade.java +++ b/core/src/main/java/io/questdb/client/std/FilesFacade.java @@ -87,6 +87,22 @@ public interface FilesFacade { long length(int fd); + /** + * Stat length of the file at {@code path}, in bytes. Default delegates to + * {@link Files#length(String)}. + * + *

Test injection point: {@code MmapSegment.openExisting} maps the file to + * this length and scans straight out of the mapping, so a wrapping facade + * that returns a value larger than the real file makes the mapping + * extend past end-of-file. A read of a page beyond real EOF raises SIGBUS on + * every filesystem (which HotSpot translates to a catchable + * {@code InternalError} at an {@code Unsafe} intrinsic site) — the same fault + * a genuinely unbacked/sparse page raises on ZFS, but reproduced + * deterministically on ext4/xfs too. That is what lets recovery's mmap-fault + * guard be regression-tested on any CI runner rather than only on ZFS. + */ + long length(String path); + int lock(int fd); int mkdir(String path, int mode); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java index 7b0db7f0..629c6624 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -27,6 +27,7 @@ import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException; import io.questdb.client.std.Files; +import io.questdb.client.std.FilesFacade; import io.questdb.client.std.MemoryTag; import io.questdb.client.std.Unsafe; import io.questdb.client.test.tools.TestUtils; @@ -70,6 +71,18 @@ * the JVM. A hole-zero-filling filesystem (ext4) instead reads the hole back as * zeroes, which fails the frame CRC; either way recovery must stop at the same * boundary and recover the same frames. + *

+ * Fail-on-revert on any filesystem. The sparse-hole tests above only + * fault on ZFS: on ext4/xfs the within-EOF hole zero-fills, so the scan stops + * via the CRC-mismatch / bad-magic branch and they stay green even with the + * mmap-fault guard reverted -- no regression protection on the ext4/xfs CI + * runners. The two {@code MapPastEof} tests below close that gap portably. + * They truncate the file down (freeing the tail blocks) and hand + * {@code openExisting} a {@link FilesFacade} that reports the original, larger + * length, so the mapping extends past real end-of-file. A read of a page beyond + * real EOF raises SIGBUS on every filesystem -- the same catchable + * {@code InternalError} an unbacked ZFS page raises -- so they exercise the + * real fault path (and fail on revert) on ext4/xfs too, not only on ZFS. */ public class MmapSegmentRecoveryFaultTest { @@ -187,6 +200,96 @@ public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception { }); } + /** + * Portable fail-on-revert guard for the recovery mmap-fault handling on the + * scan path. Unlike {@link #testRecoveryKeepsFramesBeforeUnbackedTail} + * (which only faults on ZFS), this maps the file past real EOF via the + * length-injecting facade, so the scan's read of the beyond-EOF page faults + * on ext4/xfs too. The fix must recognize that fault and keep + * recovery safe -- never a JVM abort, never a raw {@code InternalError} + * escaping into {@code SegmentRing}'s recovery loop. Revert the + * {@code scanFrames}/{@code openExisting} mmap-fault guard (or fold the CRC + * back through native {@code Crc32c}) and this errors or aborts the fork. + *

+ * Two handled outcomes are accepted, because which one occurs depends on + * whether the recovery methods are JIT-compiled at fault time: + *

    + *
  • Interpreter / C1: {@code scanFrames}'s own + * {@code catch (InternalError)} fires, so the frame below the tear is + * recovered and a usable segment is returned.
  • + *
  • C2: once {@code scanFrames} is inlined into + * {@code openExisting}, HotSpot delivers the async unsafe-access + * {@code InternalError} to {@code openExisting}'s outer + * {@code catch (Throwable)} instead of the inlined inner one, which + * converts the file to a skippable {@link MmapSegmentException}. + * Still fully handled -- {@code SegmentRing} skips just this + * {@code .sfa} rather than aborting the slot.
  • + *
+ * (The C2 delivery imprecision is a property of HotSpot's async + * unsafe-access fault handling, not of this seam; the seam only makes it + * reproducible off ZFS.) + */ + @Test + public void testScanFaultOnMapPastEofIsHandledAnyFilesystem() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final String path = tmpDir + "/seg-mappasteof-scan.sfa"; + final long page = Files.PAGE_SIZE; + // One frame that ends exactly on the first page boundary. + final int payloadLen = (int) (page - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); + long boundary = writeSegment(path, 5L, new int[]{payloadLen}); + assertEquals("frame must fill exactly one page", page, boundary); + // Free every block past the first page: the file is now exactly one + // (fully backed) page, with nothing beyond it on disk. + truncateTo(path, page); + // Report twice the real length so openExisting maps a second, + // beyond-EOF page; the scan faults reading it on any filesystem. + FilesFacade ff = new MapPastEofFacade(path, 2 * page); + try (MmapSegment seg = MmapSegment.openExisting(ff, path)) { + // Interpreter / C1: graceful partial recovery. + assertEquals("the frame below the beyond-EOF page must be recovered", 1L, seg.frameCount()); + assertEquals("scan must stop at the beyond-EOF boundary", page, seg.publishedOffset()); + assertEquals("a beyond-EOF page is not a torn write", 0L, seg.tornTailBytes()); + } catch (MmapSegmentException skippedUnderC2) { + // C2: the inlined fault escaped to openExisting's outer catch and + // was converted to a per-file skip. Assert it is the recognized + // mmap fault (not some other data error) so a revert -- which + // lets a raw InternalError through instead -- still fails here. + assertTrue(skippedUnderC2.getMessage(), + skippedUnderC2.getMessage().contains("unsafe memory access operation")); + } + }); + } + + /** + * Portable fail-on-revert guard for the {@code openExisting} header-block + * guard. The file is truncated to empty and the facade reports a full page, + * so the very first header read (magic) lands on a beyond-EOF page and + * faults on any filesystem. {@code openExisting} must convert that to a + * {@link MmapSegmentException} -- the per-file signal {@code SegmentRing} + * skips on -- not let the raw {@code InternalError} escape and abort recovery + * of every sibling. Revert the header-block conversion and this throws + * {@code InternalError} instead of {@code MmapSegmentException}. + */ + @Test + public void testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem() throws Exception { + TestUtils.assertMemoryLeak(() -> { + final String path = tmpDir + "/seg-mappasteof-header.sfa"; + final long page = Files.PAGE_SIZE; + writeSegment(path, 9L, new int[]{64}); + // Free every block: the file is now empty, so even page 0 (the + // header) is beyond EOF under the reported one-page mapping. + truncateTo(path, 0L); + FilesFacade ff = new MapPastEofFacade(path, page); + try { + MmapSegment.openExisting(ff, path).close(); + fail("expected MmapSegmentException for a beyond-EOF header page"); + } catch (MmapSegmentException expected) { + // ok -- SegmentRing's per-file catch skips just this file + // instead of aborting recovery of the whole slot. + } + }); + } + /** * Creates a segment at {@code path} and appends one frame per entry in * {@code payloadLens} (each filled with non-zero bytes so recovery can tell @@ -231,4 +334,167 @@ private static void punchSparseTail(String path, long keepBytes) { Files.close(fd); } } + + /** + * Shrinks the file to {@code keepBytes}, freeing every block past it, and + * leaves it there (no re-extend). Combined with a facade that reports a + * larger length, the freed region becomes a beyond-EOF part of the mapping + * that faults on read on any filesystem. + */ + private static void truncateTo(String path, long keepBytes) { + int fd = Files.openRW(path); + assertTrue("openRW failed", fd >= 0); + try { + assertTrue("truncate failed", Files.truncate(fd, keepBytes)); + } finally { + Files.close(fd); + } + } + + /** + * A {@link FilesFacade} that reports an inflated stat length for one target + * path so {@code openExisting} maps that file past end-of-file (see + * {@link FilesFacade#length(String)}); every other call, including + * {@code length} for any other path, delegates to the production + * {@link FilesFacade#INSTANCE}. + */ + private static final class MapPastEofFacade implements FilesFacade { + private final long reportedLength; + private final String targetPath; + + MapPastEofFacade(String targetPath, long reportedLength) { + this.targetPath = targetPath; + this.reportedLength = reportedLength; + } + + @Override + public boolean allocate(int fd, long size) { + return INSTANCE.allocate(fd, size); + } + + @Override + public long allocNativePath(String path) { + return INSTANCE.allocNativePath(path); + } + + @Override + public int close(int fd) { + return INSTANCE.close(fd); + } + + @Override + public boolean exists(String path) { + return INSTANCE.exists(path); + } + + @Override + public void findClose(long findPtr) { + INSTANCE.findClose(findPtr); + } + + @Override + public long findFirst(String dir) { + return INSTANCE.findFirst(dir); + } + + @Override + public long findName(long findPtr) { + return INSTANCE.findName(findPtr); + } + + @Override + public int findNext(long findPtr) { + return INSTANCE.findNext(findPtr); + } + + @Override + public int findType(long findPtr) { + return INSTANCE.findType(findPtr); + } + + @Override + public void freeNativePath(long pathPtr) { + INSTANCE.freeNativePath(pathPtr); + } + + @Override + public int fsync(int fd) { + return INSTANCE.fsync(fd); + } + + @Override + public long length(int fd) { + return INSTANCE.length(fd); + } + + @Override + public long length(long pathPtr) { + return INSTANCE.length(pathPtr); + } + + @Override + public long length(String path) { + return targetPath.equals(path) ? reportedLength : INSTANCE.length(path); + } + + @Override + public int lock(int fd) { + return INSTANCE.lock(fd); + } + + @Override + public int mkdir(String path, int mode) { + return INSTANCE.mkdir(path, mode); + } + + @Override + public int openCleanRW(String path) { + return INSTANCE.openCleanRW(path); + } + + @Override + public int openCleanRW(long pathPtr) { + return INSTANCE.openCleanRW(pathPtr); + } + + @Override + public int openRW(String path) { + return INSTANCE.openRW(path); + } + + @Override + public int openRW(long pathPtr) { + return INSTANCE.openRW(pathPtr); + } + + @Override + public long read(int fd, long addr, long len, long offset) { + return INSTANCE.read(fd, addr, len, offset); + } + + @Override + public boolean remove(String path) { + return INSTANCE.remove(path); + } + + @Override + public boolean remove(long pathPtr) { + return INSTANCE.remove(pathPtr); + } + + @Override + public int rename(String oldPath, String newPath) { + return INSTANCE.rename(oldPath, newPath); + } + + @Override + public boolean truncate(int fd, long size) { + return INSTANCE.truncate(fd, size); + } + + @Override + public long write(int fd, long addr, long len, long offset) { + return INSTANCE.write(fd, addr, len, offset); + } + } } diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java index 164fce12..177ee5f6 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java @@ -563,6 +563,11 @@ public long length(int fd) { return INSTANCE.length(fd); } + @Override + public long length(String path) { + return INSTANCE.length(path); + } + @Override public int lock(int fd) { return INSTANCE.lock(fd); From 9674fc55e0e0bf953d658fce1e6ddf0e3cc4aa74 Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Thu, 9 Jul 2026 07:43:47 +0300 Subject: [PATCH 6/7] test(qwp): record JDK-8 verification of the recovery mmap-fault mechanism (#64 M2) The recovery fix rests on HotSpot delivering a catchable InternalError (message containing "unsafe memory access operation") at an Unsafe access and on a direct try/catch catching it. Prior review verified this on JDK 17/21/25 and inferred JDK 8 from the adjacent pre-21 LTS releases, but JDK 8 is the shipping/CI source of truth and the original flake was on a JDK-8 ZFS runner, so it had to be checked there directly. Verified on Temurin 1.8.0_492: MmapSegmentRecoveryFaultTest passes 5/5 in both -Xint and JIT modes; HotSpot emits "a fault occurred in a recent unsafe memory access operation in compiled Java code"; a direct try/catch catches the fault in interpreter, C1, and C2 modes; isMmapAccessFault's shared fragment matches while the JDK 21+-only needle it replaced does not. The build-jdk8 CI job already runs this test on JDK 8 on every push, so the gate stays enforced. Documents the result in the test's class javadoc; no behavior change. Co-Authored-By: Claude Opus 4.8 --- .../client/sf/cursor/MmapSegmentRecoveryFaultTest.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java index 629c6624..9d7c9af3 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -63,6 +63,14 @@ * so a reflection-based test spuriously fails on the shipping JDK 8/11/17 * even though the direct-call production path catches it fine. * + * The fault-delivery mechanism the fix rests on was verified directly on the + * shipping/CI Java floor -- JDK 8 (Temurin 1.8.0_492) -- not merely inferred + * from the adjacent pre-21 LTS releases: the whole class passes there in both + * interpreter ({@code -Xint}) and JIT modes, HotSpot emits the exact pre-21 + * message above, and a direct {@code try/catch} catches the fault in + * interpreter, C1, and C2 modes. {@code isMmapAccessFault}'s shared + * {@code "unsafe memory access operation"} fragment matches that message while + * the JDK 21+-only needle it replaced does not -- the guard is live on JDK 8. * The unbacked tail is produced portably by truncating the file down (dropping * the tail blocks) and back up to the mapping size (leaving a sparse hole). A * hole-faulting filesystem (ZFS) then faults on the read exactly as in From 20aaf374fd0a56b3904c2ac584d3d8e90ba408d8 Mon Sep 17 00:00:00 2001 From: Andrei Pechkurov Date: Thu, 9 Jul 2026 10:37:02 +0300 Subject: [PATCH 7/7] style(qwp): javadoc buildCrc32cTable; fix imprecise test msg (#64 m2) Address the two cosmetic follow-ups from the #64 review: - buildCrc32cTable now uses a /** */ javadoc, matching its sibling private helpers crc32cRecovery/detectTornTail (was a // comment). - The payload-unbacked test asserted the frame-2 header "must end on the page boundary"; it actually ends 8 bytes below it (the asserted boundary-8 value, per the javadoc above). Reword to match. Co-Authored-By: Claude Opus 4.8 --- .../client/cutlass/qwp/client/sf/cursor/MmapSegment.java | 9 ++++++--- .../client/sf/cursor/MmapSegmentRecoveryFaultTest.java | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index c2b6792c..78e5db9f 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -668,9 +668,12 @@ private static int crc32cRecovery(long addr, long len) { return ~crc; } - // Standard reflected CRC-32C byte table (polynomial 0x82F63B78), matching - // crc32c_table[0] in the native crc32c.c. Computed at class init to avoid - // 256 hand-transcribed literals; drives crc32cRecovery. + /** + * Standard reflected CRC-32C byte table (polynomial {@code 0x82F63B78}), + * matching {@code crc32c_table[0]} in the native {@code crc32c.c}. Computed + * at class init to avoid 256 hand-transcribed literals; drives + * {@link #crc32cRecovery}. + */ private static int[] buildCrc32cTable() { int[] table = new int[256]; for (int n = 0; n < 256; n++) { diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java index 9d7c9af3..861f761c 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentRecoveryFaultTest.java @@ -162,7 +162,7 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception { final int payloadLen1 = (int) (frame2Offset - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); long used = writeSegment(path, 11L, new int[]{payloadLen1, payloadLen2}); - assertEquals("frame 2's header must end on the page boundary", boundary - 8, frame2Offset + MmapSegment.FRAME_HEADER_SIZE); + assertEquals("frame 2's header must end 8 bytes below the page boundary", boundary - 8, frame2Offset + MmapSegment.FRAME_HEADER_SIZE); assertTrue("frame 2 payload must reach past the boundary", used > boundary); punchSparseTail(path, boundary);