|
27 | 27 | import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment; |
28 | 28 | import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException; |
29 | 29 | import io.questdb.client.std.Files; |
| 30 | +import io.questdb.client.std.FilesFacade; |
30 | 31 | import io.questdb.client.std.MemoryTag; |
31 | 32 | import io.questdb.client.std.Unsafe; |
32 | 33 | import io.questdb.client.test.tools.TestUtils; |
|
70 | 71 | * the JVM. A hole-zero-filling filesystem (ext4) instead reads the hole back as |
71 | 72 | * zeroes, which fails the frame CRC; either way recovery must stop at the same |
72 | 73 | * boundary and recover the same frames. |
| 74 | + * <p> |
| 75 | + * <b>Fail-on-revert on any filesystem.</b> The sparse-hole tests above only |
| 76 | + * fault on ZFS: on ext4/xfs the within-EOF hole zero-fills, so the scan stops |
| 77 | + * via the CRC-mismatch / bad-magic branch and they stay green even with the |
| 78 | + * mmap-fault guard reverted -- no regression protection on the ext4/xfs CI |
| 79 | + * runners. The two {@code MapPastEof} tests below close that gap portably. |
| 80 | + * They truncate the file <em>down</em> (freeing the tail blocks) and hand |
| 81 | + * {@code openExisting} a {@link FilesFacade} that reports the original, larger |
| 82 | + * length, so the mapping extends past real end-of-file. A read of a page beyond |
| 83 | + * real EOF raises SIGBUS on <em>every</em> filesystem -- the same catchable |
| 84 | + * {@code InternalError} an unbacked ZFS page raises -- so they exercise the |
| 85 | + * real fault path (and fail on revert) on ext4/xfs too, not only on ZFS. |
73 | 86 | */ |
74 | 87 | public class MmapSegmentRecoveryFaultTest { |
75 | 88 |
|
@@ -187,6 +200,96 @@ public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception { |
187 | 200 | }); |
188 | 201 | } |
189 | 202 |
|
| 203 | + /** |
| 204 | + * Portable fail-on-revert guard for the recovery mmap-fault handling on the |
| 205 | + * scan path. Unlike {@link #testRecoveryKeepsFramesBeforeUnbackedTail} |
| 206 | + * (which only faults on ZFS), this maps the file past real EOF via the |
| 207 | + * length-injecting facade, so the scan's read of the beyond-EOF page faults |
| 208 | + * on ext4/xfs too. The fix must <em>recognize</em> that fault and keep |
| 209 | + * recovery safe -- never a JVM abort, never a raw {@code InternalError} |
| 210 | + * escaping into {@code SegmentRing}'s recovery loop. Revert the |
| 211 | + * {@code scanFrames}/{@code openExisting} mmap-fault guard (or fold the CRC |
| 212 | + * back through native {@code Crc32c}) and this errors or aborts the fork. |
| 213 | + * <p> |
| 214 | + * Two handled outcomes are accepted, because which one occurs depends on |
| 215 | + * whether the recovery methods are JIT-compiled at fault time: |
| 216 | + * <ul> |
| 217 | + * <li><b>Interpreter / C1:</b> {@code scanFrames}'s own |
| 218 | + * {@code catch (InternalError)} fires, so the frame below the tear is |
| 219 | + * recovered and a usable segment is returned.</li> |
| 220 | + * <li><b>C2:</b> once {@code scanFrames} is inlined into |
| 221 | + * {@code openExisting}, HotSpot delivers the async unsafe-access |
| 222 | + * {@code InternalError} to {@code openExisting}'s outer |
| 223 | + * {@code catch (Throwable)} instead of the inlined inner one, which |
| 224 | + * converts the file to a skippable {@link MmapSegmentException}. |
| 225 | + * Still fully handled -- {@code SegmentRing} skips just this |
| 226 | + * {@code .sfa} rather than aborting the slot.</li> |
| 227 | + * </ul> |
| 228 | + * (The C2 delivery imprecision is a property of HotSpot's async |
| 229 | + * unsafe-access fault handling, not of this seam; the seam only makes it |
| 230 | + * reproducible off ZFS.) |
| 231 | + */ |
| 232 | + @Test |
| 233 | + public void testScanFaultOnMapPastEofIsHandledAnyFilesystem() throws Exception { |
| 234 | + TestUtils.assertMemoryLeak(() -> { |
| 235 | + final String path = tmpDir + "/seg-mappasteof-scan.sfa"; |
| 236 | + final long page = Files.PAGE_SIZE; |
| 237 | + // One frame that ends exactly on the first page boundary. |
| 238 | + final int payloadLen = (int) (page - MmapSegment.HEADER_SIZE - MmapSegment.FRAME_HEADER_SIZE); |
| 239 | + long boundary = writeSegment(path, 5L, new int[]{payloadLen}); |
| 240 | + assertEquals("frame must fill exactly one page", page, boundary); |
| 241 | + // Free every block past the first page: the file is now exactly one |
| 242 | + // (fully backed) page, with nothing beyond it on disk. |
| 243 | + truncateTo(path, page); |
| 244 | + // Report twice the real length so openExisting maps a second, |
| 245 | + // beyond-EOF page; the scan faults reading it on any filesystem. |
| 246 | + FilesFacade ff = new MapPastEofFacade(path, 2 * page); |
| 247 | + try (MmapSegment seg = MmapSegment.openExisting(ff, path)) { |
| 248 | + // Interpreter / C1: graceful partial recovery. |
| 249 | + assertEquals("the frame below the beyond-EOF page must be recovered", 1L, seg.frameCount()); |
| 250 | + assertEquals("scan must stop at the beyond-EOF boundary", page, seg.publishedOffset()); |
| 251 | + assertEquals("a beyond-EOF page is not a torn write", 0L, seg.tornTailBytes()); |
| 252 | + } catch (MmapSegmentException skippedUnderC2) { |
| 253 | + // C2: the inlined fault escaped to openExisting's outer catch and |
| 254 | + // was converted to a per-file skip. Assert it is the recognized |
| 255 | + // mmap fault (not some other data error) so a revert -- which |
| 256 | + // lets a raw InternalError through instead -- still fails here. |
| 257 | + assertTrue(skippedUnderC2.getMessage(), |
| 258 | + skippedUnderC2.getMessage().contains("unsafe memory access operation")); |
| 259 | + } |
| 260 | + }); |
| 261 | + } |
| 262 | + |
| 263 | + /** |
| 264 | + * Portable fail-on-revert guard for the {@code openExisting} header-block |
| 265 | + * guard. The file is truncated to empty and the facade reports a full page, |
| 266 | + * so the very first header read (magic) lands on a beyond-EOF page and |
| 267 | + * faults on any filesystem. {@code openExisting} must convert that to a |
| 268 | + * {@link MmapSegmentException} -- the per-file signal {@code SegmentRing} |
| 269 | + * skips on -- not let the raw {@code InternalError} escape and abort recovery |
| 270 | + * of every sibling. Revert the header-block conversion and this throws |
| 271 | + * {@code InternalError} instead of {@code MmapSegmentException}. |
| 272 | + */ |
| 273 | + @Test |
| 274 | + public void testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem() throws Exception { |
| 275 | + TestUtils.assertMemoryLeak(() -> { |
| 276 | + final String path = tmpDir + "/seg-mappasteof-header.sfa"; |
| 277 | + final long page = Files.PAGE_SIZE; |
| 278 | + writeSegment(path, 9L, new int[]{64}); |
| 279 | + // Free every block: the file is now empty, so even page 0 (the |
| 280 | + // header) is beyond EOF under the reported one-page mapping. |
| 281 | + truncateTo(path, 0L); |
| 282 | + FilesFacade ff = new MapPastEofFacade(path, page); |
| 283 | + try { |
| 284 | + MmapSegment.openExisting(ff, path).close(); |
| 285 | + fail("expected MmapSegmentException for a beyond-EOF header page"); |
| 286 | + } catch (MmapSegmentException expected) { |
| 287 | + // ok -- SegmentRing's per-file catch skips just this file |
| 288 | + // instead of aborting recovery of the whole slot. |
| 289 | + } |
| 290 | + }); |
| 291 | + } |
| 292 | + |
190 | 293 | /** |
191 | 294 | * Creates a segment at {@code path} and appends one frame per entry in |
192 | 295 | * {@code payloadLens} (each filled with non-zero bytes so recovery can tell |
@@ -231,4 +334,167 @@ private static void punchSparseTail(String path, long keepBytes) { |
231 | 334 | Files.close(fd); |
232 | 335 | } |
233 | 336 | } |
| 337 | + |
| 338 | + /** |
| 339 | + * Shrinks the file to {@code keepBytes}, freeing every block past it, and |
| 340 | + * leaves it there (no re-extend). Combined with a facade that reports a |
| 341 | + * larger length, the freed region becomes a beyond-EOF part of the mapping |
| 342 | + * that faults on read on any filesystem. |
| 343 | + */ |
| 344 | + private static void truncateTo(String path, long keepBytes) { |
| 345 | + int fd = Files.openRW(path); |
| 346 | + assertTrue("openRW failed", fd >= 0); |
| 347 | + try { |
| 348 | + assertTrue("truncate failed", Files.truncate(fd, keepBytes)); |
| 349 | + } finally { |
| 350 | + Files.close(fd); |
| 351 | + } |
| 352 | + } |
| 353 | + |
| 354 | + /** |
| 355 | + * A {@link FilesFacade} that reports an inflated stat length for one target |
| 356 | + * path so {@code openExisting} maps that file past end-of-file (see |
| 357 | + * {@link FilesFacade#length(String)}); every other call, including |
| 358 | + * {@code length} for any other path, delegates to the production |
| 359 | + * {@link FilesFacade#INSTANCE}. |
| 360 | + */ |
| 361 | + private static final class MapPastEofFacade implements FilesFacade { |
| 362 | + private final long reportedLength; |
| 363 | + private final String targetPath; |
| 364 | + |
| 365 | + MapPastEofFacade(String targetPath, long reportedLength) { |
| 366 | + this.targetPath = targetPath; |
| 367 | + this.reportedLength = reportedLength; |
| 368 | + } |
| 369 | + |
| 370 | + @Override |
| 371 | + public boolean allocate(int fd, long size) { |
| 372 | + return INSTANCE.allocate(fd, size); |
| 373 | + } |
| 374 | + |
| 375 | + @Override |
| 376 | + public long allocNativePath(String path) { |
| 377 | + return INSTANCE.allocNativePath(path); |
| 378 | + } |
| 379 | + |
| 380 | + @Override |
| 381 | + public int close(int fd) { |
| 382 | + return INSTANCE.close(fd); |
| 383 | + } |
| 384 | + |
| 385 | + @Override |
| 386 | + public boolean exists(String path) { |
| 387 | + return INSTANCE.exists(path); |
| 388 | + } |
| 389 | + |
| 390 | + @Override |
| 391 | + public void findClose(long findPtr) { |
| 392 | + INSTANCE.findClose(findPtr); |
| 393 | + } |
| 394 | + |
| 395 | + @Override |
| 396 | + public long findFirst(String dir) { |
| 397 | + return INSTANCE.findFirst(dir); |
| 398 | + } |
| 399 | + |
| 400 | + @Override |
| 401 | + public long findName(long findPtr) { |
| 402 | + return INSTANCE.findName(findPtr); |
| 403 | + } |
| 404 | + |
| 405 | + @Override |
| 406 | + public int findNext(long findPtr) { |
| 407 | + return INSTANCE.findNext(findPtr); |
| 408 | + } |
| 409 | + |
| 410 | + @Override |
| 411 | + public int findType(long findPtr) { |
| 412 | + return INSTANCE.findType(findPtr); |
| 413 | + } |
| 414 | + |
| 415 | + @Override |
| 416 | + public void freeNativePath(long pathPtr) { |
| 417 | + INSTANCE.freeNativePath(pathPtr); |
| 418 | + } |
| 419 | + |
| 420 | + @Override |
| 421 | + public int fsync(int fd) { |
| 422 | + return INSTANCE.fsync(fd); |
| 423 | + } |
| 424 | + |
| 425 | + @Override |
| 426 | + public long length(int fd) { |
| 427 | + return INSTANCE.length(fd); |
| 428 | + } |
| 429 | + |
| 430 | + @Override |
| 431 | + public long length(long pathPtr) { |
| 432 | + return INSTANCE.length(pathPtr); |
| 433 | + } |
| 434 | + |
| 435 | + @Override |
| 436 | + public long length(String path) { |
| 437 | + return targetPath.equals(path) ? reportedLength : INSTANCE.length(path); |
| 438 | + } |
| 439 | + |
| 440 | + @Override |
| 441 | + public int lock(int fd) { |
| 442 | + return INSTANCE.lock(fd); |
| 443 | + } |
| 444 | + |
| 445 | + @Override |
| 446 | + public int mkdir(String path, int mode) { |
| 447 | + return INSTANCE.mkdir(path, mode); |
| 448 | + } |
| 449 | + |
| 450 | + @Override |
| 451 | + public int openCleanRW(String path) { |
| 452 | + return INSTANCE.openCleanRW(path); |
| 453 | + } |
| 454 | + |
| 455 | + @Override |
| 456 | + public int openCleanRW(long pathPtr) { |
| 457 | + return INSTANCE.openCleanRW(pathPtr); |
| 458 | + } |
| 459 | + |
| 460 | + @Override |
| 461 | + public int openRW(String path) { |
| 462 | + return INSTANCE.openRW(path); |
| 463 | + } |
| 464 | + |
| 465 | + @Override |
| 466 | + public int openRW(long pathPtr) { |
| 467 | + return INSTANCE.openRW(pathPtr); |
| 468 | + } |
| 469 | + |
| 470 | + @Override |
| 471 | + public long read(int fd, long addr, long len, long offset) { |
| 472 | + return INSTANCE.read(fd, addr, len, offset); |
| 473 | + } |
| 474 | + |
| 475 | + @Override |
| 476 | + public boolean remove(String path) { |
| 477 | + return INSTANCE.remove(path); |
| 478 | + } |
| 479 | + |
| 480 | + @Override |
| 481 | + public boolean remove(long pathPtr) { |
| 482 | + return INSTANCE.remove(pathPtr); |
| 483 | + } |
| 484 | + |
| 485 | + @Override |
| 486 | + public int rename(String oldPath, String newPath) { |
| 487 | + return INSTANCE.rename(oldPath, newPath); |
| 488 | + } |
| 489 | + |
| 490 | + @Override |
| 491 | + public boolean truncate(int fd, long size) { |
| 492 | + return INSTANCE.truncate(fd, size); |
| 493 | + } |
| 494 | + |
| 495 | + @Override |
| 496 | + public long write(int fd, long addr, long len, long offset) { |
| 497 | + return INSTANCE.write(fd, addr, len, offset); |
| 498 | + } |
| 499 | + } |
234 | 500 | } |
0 commit comments