Skip to content

Commit 8c53638

Browse files
glasstigerclaude
andcommitted
Route recovery header-fault tests via SegmentRing
The SF store-and-forward recovery header-fault test flaked the JDK 8 CI. It called MmapSegment.openExisting directly from the test lambda, so under C2 HotSpot inlined openExisting into the handler-less test frame and delivered the async "recent unsafe memory access" InternalError past openExisting's own catch -- the conversion to a MmapSegmentException never ran and a raw InternalError escaped, failing the test. Route the two header-skippable tests through SegmentRing.openExisting, production's real recovery caller. There the faulting header read sits behind a genuine, non-inlined call frame whose catch converts the fault to a MmapSegmentException, which SegmentRing's per-file catch skips -- so recovery of the sole unreadable segment yields no ring, exactly as in production. Fail-on-revert is preserved: revert the header-block conversion and the raw InternalError escapes SegmentRing and the test errors. Add a facade-aware SegmentRing.openExisting(FilesFacade, ...) overload so the length-injecting test facade reaches MmapSegment.openExisting, mirroring MmapSegment's own existing facade seam. The INSTANCE overload production uses is unchanged. Verified with the client's native lib built locally: the routed header tests pass under full-suite JIT warmup on JDK 11 (6/6, the pre-21 proxy for the JDK 8 CI) and on JDK 17 (13/13). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5cc68a9 commit 8c53638

2 files changed

Lines changed: 76 additions & 27 deletions

File tree

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

Lines changed: 23 additions & 1 deletion
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;
@@ -155,6 +156,27 @@ public SegmentRing(MmapSegment initialActive, long maxBytesPerSegment) {
155156
* depends on every segment being readable.
156157
*/
157158
public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
159+
return openExisting(FilesFacade.INSTANCE, sfDir, maxBytesPerSegment);
160+
}
161+
162+
/**
163+
* Facade-aware variant of {@link #openExisting(String, long)} that opens
164+
* each recovered segment through the given {@link FilesFacade} -- via
165+
* {@link MmapSegment#openExisting(FilesFacade, String)} -- instead of
166+
* {@link FilesFacade#INSTANCE}. Production calls the {@code INSTANCE}
167+
* overload above; this seam exists so recovery's mmap-fault handling can be
168+
* regression-tested end to end on any filesystem. A facade that reports an
169+
* inflated length maps a segment past real end-of-file, so the recovery
170+
* read faults deterministically -- the same catchable {@code InternalError}
171+
* an unbacked/sparse page raises on ZFS, but reproduced on ext4/xfs where a
172+
* within-EOF hole would instead zero-fill and never exercise the guard.
173+
* <p>
174+
* Only the per-segment open is routed through {@code ff}; directory
175+
* enumeration and the empty-orphan unlink/quarantine stay on {@link Files},
176+
* mirroring {@code MmapSegment.openExisting}'s own partial seam (which keeps
177+
* {@code mmap} on {@code Files}).
178+
*/
179+
public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxBytesPerSegment) {
158180
if (!Files.exists(sfDir)) {
159181
return null;
160182
}
@@ -182,7 +204,7 @@ public static SegmentRing openExisting(String sfDir, long maxBytesPerSegment) {
182204
String path = sfDir + "/" + name;
183205
MmapSegment seg = null;
184206
try {
185-
seg = MmapSegment.openExisting(path);
207+
seg = MmapSegment.openExisting(ff, path);
186208
// Filter out empty leftovers -- typically hot-spare
187209
// segments the manager pre-allocated for a prior
188210
// session that never got rotated into active. They

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

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
2828
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException;
29+
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
2930
import io.questdb.client.std.Files;
3031
import io.questdb.client.std.FilesFacade;
3132
import io.questdb.client.std.MemoryTag;
@@ -36,8 +37,8 @@
3637
import org.junit.Test;
3738

3839
import static org.junit.Assert.assertEquals;
40+
import static org.junit.Assert.assertNull;
3941
import static org.junit.Assert.assertTrue;
40-
import static org.junit.Assert.fail;
4142

4243
/**
4344
* Regression guard for the recovery-time SIGBUS hazard in {@link MmapSegment}.
@@ -52,8 +53,12 @@
5253
* keep every frame below it, and hand back a usable segment -- not let the
5354
* error abort recovery of the whole slot (the reported ZFS-CI flake).
5455
* <p>
55-
* These tests drive the <b>production entry point</b> ({@code openExisting}),
56-
* not the private scan methods via reflection. That matters for two reasons:
56+
* These tests drive the <b>production entry points</b> -- the two
57+
* header-skippable cases go through the outermost one,
58+
* {@code SegmentRing.openExisting} (production's real recovery caller, whose
59+
* per-file catch does the skip), and the rest through
60+
* {@code MmapSegment.openExisting} -- not the private scan methods via
61+
* reflection. That matters for two reasons:
5762
* <ul>
5863
* <li>It exercises the real recovery path end to end.</li>
5964
* <li>On pre-21 JDKs the mmap-fault {@code InternalError} is delivered
@@ -86,7 +91,7 @@
8691
* mmap-fault guard reverted -- no regression protection on the ext4/xfs CI
8792
* runners. The two {@code MapPastEof} tests below close that gap portably.
8893
* They truncate the file <em>down</em> (freeing the tail blocks) and hand
89-
* {@code openExisting} a {@link FilesFacade} that reports the original, larger
94+
* recovery a {@link FilesFacade} that reports the original, larger
9095
* length, so the mapping extends past real end-of-file. A read of a page beyond
9196
* real EOF raises SIGBUS on <em>every</em> filesystem -- the same catchable
9297
* {@code InternalError} an unbacked ZFS page raises -- so they exercise the
@@ -181,7 +186,7 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
181186
* M1 regression: the header block (magic/version/baseSeq) is read before
182187
* {@code scanFrames}, so an unbacked page 0 faults ahead of the guarded
183188
* scan. {@link MmapSegment#openExisting} must surface that as a
184-
* {@link MmapSegmentException} -- the per-file signal {@code SegmentRing}
189+
* {@link MmapSegmentException} -- the per-file signal {@link SegmentRing}
185190
* catches to skip just this {@code .sfa} -- and never let the raw
186191
* {@code InternalError} escape and abort recovery of every sibling segment.
187192
* <p>
@@ -190,6 +195,15 @@ public void testRecoverySurvivesPayloadReachingUnbackedPage() throws Exception {
190195
* catch; on a hole-zero-filling FS (ext4) page 0 reads back as zeroes, so
191196
* the magic check fails and throws {@code MmapSegmentException} directly.
192197
* Either way the file is skippable, not fatal.
198+
* <p>
199+
* Driven through {@link SegmentRing#openExisting} -- the real recovery path
200+
* that performs the per-file skip -- so the assertion is exactly the
201+
* production outcome: the sole unreadable segment is skipped and recovery
202+
* yields no ring. Going through {@code SegmentRing} also keeps the faulting
203+
* header read behind a real (non-inlined) call frame, so that on ZFS
204+
* HotSpot's C2 cannot deliver the async unsafe-access fault past
205+
* {@code openExisting}'s catch the way a direct call can -- see
206+
* {@link #testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem}.
193207
*/
194208
@Test
195209
public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception {
@@ -198,13 +212,10 @@ public void testUnbackedHeaderPageIsSkippableNotFatal() throws Exception {
198212
writeSegment(path, 3L, new int[]{64});
199213
// Punch the whole file into a hole -- page 0 (the header) included.
200214
punchSparseTail(path, 0L);
201-
try {
202-
MmapSegment.openExisting(path).close();
203-
fail("expected MmapSegmentException for an unbacked header page");
204-
} catch (MmapSegmentException expected) {
205-
// ok -- SegmentRing's per-file catch skips just this file
206-
// instead of aborting recovery of the whole slot.
207-
}
215+
// The sole .sfa is unreadable (a faulting or zeroed header page), so
216+
// SegmentRing skips it and recovery produces no ring at all.
217+
assertNull("an unbacked header page must be skipped, not recovered or fatal",
218+
SegmentRing.openExisting(tmpDir, SEGMENT_BYTES));
208219
});
209220
}
210221

@@ -270,13 +281,30 @@ public void testScanFaultOnMapPastEofIsHandledAnyFilesystem() throws Exception {
270281

271282
/**
272283
* Portable fail-on-revert guard for the {@code openExisting} header-block
273-
* guard. The file is truncated to empty and the facade reports a full page,
274-
* so the very first header read (magic) lands on a beyond-EOF page and
275-
* faults on any filesystem. {@code openExisting} must convert that to a
284+
* guard, driven through the real recovery entry point
285+
* {@link SegmentRing#openExisting}. The file is truncated to empty and the
286+
* facade reports a full page, so the very first header read (magic) lands on
287+
* a beyond-EOF page and faults on any filesystem.
288+
* {@link MmapSegment#openExisting} must convert that to a
276289
* {@link MmapSegmentException} -- the per-file signal {@code SegmentRing}
277-
* skips on -- not let the raw {@code InternalError} escape and abort recovery
278-
* of every sibling. Revert the header-block conversion and this throws
279-
* {@code InternalError} instead of {@code MmapSegmentException}.
290+
* catches to skip just this {@code .sfa} -- so recovery of the sole
291+
* (unreadable) segment yields no ring rather than aborting or surfacing a
292+
* raw {@code InternalError}. Revert the header-block conversion and the raw
293+
* {@code InternalError} escapes {@code openExisting}, propagates out of
294+
* {@code SegmentRing}'s recovery loop, and this errors instead of returning
295+
* {@code null}.
296+
* <p>
297+
* Routing through {@code SegmentRing} rather than calling
298+
* {@code MmapSegment.openExisting} directly is deliberate, and is what makes
299+
* this test stable on the JDK 8 CI. It mirrors production, where the
300+
* faulting header read sits behind a real (non-inlined) call frame whose
301+
* {@code catch (Throwable)} runs the fault-to-{@code MmapSegmentException}
302+
* conversion. A direct call instead lets HotSpot's C2 inline
303+
* {@code openExisting} into the handler-less test frame and deliver the
304+
* async "recent unsafe memory access" {@code InternalError} past that
305+
* inlined catch -- an artifact of the test call shape, not of any
306+
* production path -- which flaked the direct-call form on the larger
307+
* (JIT-warming) CI suite.
280308
*/
281309
@Test
282310
public void testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem() throws Exception {
@@ -285,16 +313,15 @@ public void testHeaderFaultOnMapPastEofIsSkippableAnyFilesystem() throws Excepti
285313
final long page = Files.PAGE_SIZE;
286314
writeSegment(path, 9L, new int[]{64});
287315
// Free every block: the file is now empty, so even page 0 (the
288-
// header) is beyond EOF under the reported one-page mapping.
316+
// header) is beyond EOF under the reported one-page mapping. The
317+
// facade reports a full page, so openExisting maps that beyond-EOF
318+
// page and the header read faults on it on any filesystem.
289319
truncateTo(path, 0L);
290320
FilesFacade ff = new MapPastEofFacade(path, page);
291-
try {
292-
MmapSegment.openExisting(ff, path).close();
293-
fail("expected MmapSegmentException for a beyond-EOF header page");
294-
} catch (MmapSegmentException expected) {
295-
// ok -- SegmentRing's per-file catch skips just this file
296-
// instead of aborting recovery of the whole slot.
297-
}
321+
// SegmentRing opens the sole segment through the facade, catches the
322+
// converted MmapSegmentException, skips the file, and returns no ring.
323+
assertNull("a beyond-EOF header page must be skipped, not recovered or fatal",
324+
SegmentRing.openExisting(ff, tmpDir, SEGMENT_BYTES));
298325
});
299326
}
300327

0 commit comments

Comments
 (0)