|
33 | 33 | import org.junit.Before; |
34 | 34 | import org.junit.Test; |
35 | 35 |
|
36 | | -import java.lang.reflect.Method; |
37 | 36 | import java.nio.file.Paths; |
38 | 37 |
|
39 | 38 | import static org.junit.Assert.assertEquals; |
|
42 | 41 | /** |
43 | 42 | * Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}. |
44 | 43 | * <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 |
49 | 48 | * 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). |
56 | 53 | * <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. |
64 | 73 | */ |
65 | 74 | public class MmapSegmentRecoveryFaultTest { |
66 | 75 |
|
@@ -98,61 +107,117 @@ public void tearDown() { |
98 | 107 | Files.remove(tmpDir); |
99 | 108 | } |
100 | 109 |
|
| 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 | + */ |
101 | 116 | @Test |
102 | | - public void testRecoveryScanTreatsUnbackedTailAsBoundary() throws Exception { |
| 117 | + public void testRecoveryKeepsFramesBeforeUnbackedTail() throws Exception { |
103 | 118 | TestUtils.assertMemoryLeak(() -> { |
104 | 119 | 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()); |
123 | 135 | } |
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); |
155 | 175 | } |
156 | 176 | }); |
157 | 177 | } |
| 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 | + } |
158 | 223 | } |
0 commit comments