Skip to content

Commit ece7817

Browse files
glasstigerclaude
andcommitted
Skip a segment whose mmap fault arrives late
SegmentRing's per-file arm caught only MmapSegmentException, so an unbacked or sparse mapped page in one .sfa could abort recovery of the whole slot and strand every frame the sibling segments still held. MmapSegment installs handlers around its own recovery reads, but they cannot be relied on before JDK 21: HotSpot records an unsafe-access fault and raises the InternalError at the next return or safepoint check rather than at the faulting read, so it surfaces in an arbitrary caller frame. JDK-8283699 made delivery precise only in 21+. Observed on 8 and 17 landing two frames above SegmentRing.openExisting, past every handler MmapSegment installs -- which is what left the two map-past-EOF guards in MmapSegmentRecoveryFaultTest erroring on the JDK 8 job while passing on 25. Convert at the per-file boundary instead, the frame the late delivery lands in. The arm widens to Throwable but rethrows anything that is neither MmapSegmentException nor the recognized mmap access fault, so resource exhaustion and programmer errors still propagate to the outer catch as before. isMmapAccessFault becomes package-private for that caller and carries the delivery caveat in its javadoc. Add a facade-aware SegmentRing.openExisting overload, mirroring the seam MmapSegment already exposes, and two tests that inject the fault through it deterministically on any JDK: one asserting the faulting segment is skipped while its sibling's frames survive, one asserting an unrecognized InternalError still propagates so the widened catch cannot degrade into skip-on-anything. The two pre-existing map-past-EOF tests now drive SegmentRing -- the boundary their own javadocs describe -- keeping the strong assertion where delivery is precise and degrading on pre-21 to what the JVM still makes checkable: the process survived and the escaping error is the recognized fault. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 47b3c9e commit ece7817

3 files changed

Lines changed: 250 additions & 32 deletions

File tree

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,8 +649,23 @@ public long tornTailBytes() {
649649
* fragment keeps the guard effective on JDK 8/11/17 as well as 21+, while
650650
* still being specific enough that a genuine VirtualMachineError (real OOM,
651651
* StackOverflow) is never swallowed.
652+
* <p>
653+
* <b>Delivery is NOT precise before JDK 21, so callers must guard too.</b>
654+
* Pre-21 HotSpot records the fault and raises the {@code InternalError} at
655+
* the next return or safepoint check rather than at the faulting
656+
* instruction, so it can surface in a CALLER's frame -- past every handler
657+
* in this class. Observed on JDK 8/17: a fault taken inside
658+
* {@link #scanFrames} arrives only after {@link #openExisting} has already
659+
* returned its segment, so neither the scan's own catch nor
660+
* {@code openExisting}'s outer catch ever sees it. JDK-8283699 made
661+
* delivery precise in 21+, which is why the same case is fully contained
662+
* there. The in-class catches therefore handle the common (precise) case
663+
* only; {@link SegmentRing#openExisting} applies this same predicate at the
664+
* per-file boundary so a late-delivered fault still skips one {@code .sfa}
665+
* instead of aborting recovery of the whole slot. Package-private for that
666+
* caller.
652667
*/
653-
private static boolean isMmapAccessFault(Throwable t) {
668+
static boolean isMmapAccessFault(Throwable t) {
654669
if (!(t instanceof InternalError)) {
655670
return false;
656671
}

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
package io.questdb.client.cutlass.qwp.client.sf.cursor;
2626

2727
import io.questdb.client.std.Files;
28+
import io.questdb.client.std.FilesFacade;
2829
import io.questdb.client.std.ObjList;
2930
import io.questdb.client.std.QuietCloseable;
3031
import org.jetbrains.annotations.TestOnly;
@@ -168,6 +169,21 @@ public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) {
168169
* depends on every segment being readable.
169170
*/
170171
public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
172+
return openExisting(FilesFacade.INSTANCE, sfDir, maxBytesPerSegment);
173+
}
174+
175+
/**
176+
* Facade-aware variant of {@link #openExisting(String, long)}. Only the
177+
* per-segment {@link MmapSegment#openExisting(FilesFacade, String)} call
178+
* takes the facade -- directory enumeration and the empty-segment unlink
179+
* stay on {@link Files}, because the seam exists for one purpose: letting a
180+
* facade report an inflated stat length so a segment maps past
181+
* end-of-file and its recovery reads fault deterministically on ANY
182+
* filesystem. That is what regression-tests the per-file mmap-fault skip
183+
* below, which on a real deployment only triggers on a sparse
184+
* pre-allocation tail (e.g. ZFS) or a file truncated under the mapping.
185+
*/
186+
public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxBytesPerSegment) {
171187
if (!Files.exists(sfDir)) {
172188
return null;
173189
}
@@ -197,7 +213,7 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
197213
String path = sfDir + "/" + name;
198214
MmapSegment seg = null;
199215
try {
200-
seg = MmapSegment.openExisting(path);
216+
seg = MmapSegment.openExisting(ff, path);
201217
// Filter out empty leftovers -- typically hot-spare
202218
// segments the manager pre-allocated for a prior
203219
// session that never got rotated into active. They
@@ -231,7 +247,7 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
231247
opened.add(seg);
232248
seg = null;
233249
}
234-
} catch (MmapSegmentException t) {
250+
} catch (Throwable t) {
235251
// Per-file data error (bad magic, bad header,
236252
// unsupported version, mmap rejection on this one
237253
// file). Don't take down recovery for one corrupt
@@ -243,6 +259,24 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
243259
// an OOM would just fail again on the next file
244260
// while silently leaking the segments we managed
245261
// to recover before it.
262+
//
263+
// The mmap-access-fault arm catches Throwable
264+
// rather than just MmapSegmentException because
265+
// pre-21 HotSpot delivers an unsafe-access fault
266+
// ASYNCHRONOUSLY -- the InternalError can surface
267+
// here, in MmapSegment.openExisting's caller,
268+
// instead of inside the handlers that class
269+
// installs around its own reads (see
270+
// MmapSegment.isMmapAccessFault). An unbacked or
271+
// sparse page in ONE segment must still skip only
272+
// that segment; letting the raw InternalError reach
273+
// the outer catch would abort recovery of the whole
274+
// slot and strand every frame the other segments
275+
// still hold.
276+
if (!(t instanceof MmapSegmentException)
277+
&& !MmapSegment.isMmapAccessFault(t)) {
278+
throw t;
279+
}
246280
LOG.warn("openExisting: skipping {} -- {}", path, t.toString());
247281
} finally {
248282
// Close any seg whose ownership wasn't transferred

0 commit comments

Comments
 (0)