Skip to content

Commit fd424e3

Browse files
committed
fix(qwp): drainer retries once over a chain the sealed-residue sanitize already healed
The fail-closed first-sight throw in sanitizeSealedResidue fires AFTER the proven-dead sealed residue has been durably zeroed (msync+fsync), so the chain on disk is already healed when it propagates. The orphan drainer classified that throw as terminal SfRecoveryException and dropped a .failed sentinel -- stranding a fully replayable backlog behind a quarantine no scan revisits, for an incident recovery had just repaired. Introduce SfSanitizedResidueException (a refinement of SfRecoveryException, so producer startup keeps its fail-closed restart-proves-clean semantics) and have the drainer intercept it for a single in-place construction retry. Any second failure, including a repeat of the refinement from a non-sticking heal, falls through to the existing terminal classification. New drainer test proves the full arc: poisoned sealed suffix, heal durably on disk before the throw, one retry, connect reached, no sentinel, backlog still scanner-eligible. SegmentRingTest now pins the thrown type at the sanitize site.
1 parent 38cdf3e commit fd424e3

7 files changed

Lines changed: 206 additions & 10 deletions

File tree

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

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,9 +560,29 @@ public void run() {
560560
// holds it, the engine constructor throws and we exit silently
561561
// (no .failed sentinel — contention is expected, not an error).
562562
try {
563-
engine = new CursorSendEngine(slotPath, segmentSizeBytes,
564-
sfMaxTotalBytes, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
565-
syncIntervalNanos);
563+
try {
564+
engine = new CursorSendEngine(slotPath, segmentSizeBytes,
565+
sfMaxTotalBytes, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
566+
syncIntervalNanos);
567+
} catch (SfSanitizedResidueException first) {
568+
// First sight of proven-dead sealed residue: recovery
569+
// durably zeroed it BEFORE failing closed, so the chain
570+
// on disk is already healed and a .failed sentinel here
571+
// would strand a replayable backlog no scan revisits
572+
// (the sentinel gates isCandidateOrphan and nothing in
573+
// production clears it). Retry once over the healed
574+
// chain; the WARN keeps the incident surfaced. Any
575+
// failure of the retry is genuine and takes the normal
576+
// classification below -- including a repeat of this
577+
// type, which the SfRecoveryException arm then treats
578+
// as the terminal quarantine a non-sticking heal is.
579+
LOG.warn("drainer slot {}: sealed SF residue sanitized during recovery ({}); "
580+
+ "retrying engine construction over the healed chain",
581+
slotPath, first.getMessage());
582+
engine = new CursorSendEngine(slotPath, segmentSizeBytes,
583+
sfMaxTotalBytes, CursorSendEngine.DEFAULT_APPEND_DEADLINE_NANOS,
584+
syncIntervalNanos);
585+
}
566586
} catch (SlotLockContentionException t) {
567587
LOG.info("orphan slot already locked, skipping: {} ({})",
568588
slotPath, t.getMessage());

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
* <b>positively-identified corruption</b> in one file's own bytes, which
3737
* recovery may quarantine when the surviving chain proves safe; and
3838
* {@link SfRecoveryException} marks a <b>terminal chain failure</b> that needs
39-
* operator intervention.
39+
* operator intervention (with one self-healed refinement,
40+
* {@link SfSanitizedResidueException}, that unattended callers retry once
41+
* instead of quarantining).
4042
*/
4143
public class MmapSegmentException extends RuntimeException {
4244
public MmapSegmentException(String message) {

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -784,10 +784,13 @@ private static void validateContiguous(ObjList<MmapSegment> segments) {
784784
* fails recovery before any mutation, preserving the bytes (potentially
785785
* the only copy of unreachable valid-CRC frames) for operator
786786
* extraction. With {@code failClosedOnSight} the incident is still
787-
* surfaced as a first-sight startup failure after sanitizing (the
788-
* restart then proves the chain clean); without it the chain proceeds
789-
* immediately (legacy migration, which predates the sealed-suffix
790-
* contract).
787+
* surfaced as a first-sight {@link SfSanitizedResidueException} after
788+
* sanitizing: the residue is already durably zeroed when it propagates,
789+
* so a retry proves the chain clean (attended callers get that via
790+
* restart; unattended callers key off the distinct type to retry
791+
* instead of quarantining a just-healed slot). Without the flag the
792+
* chain proceeds immediately (legacy migration, which predates the
793+
* sealed-suffix contract).
791794
*/
792795
private static void sanitizeSealedResidue(ObjList<MmapSegment> chain, boolean failClosedOnSight) {
793796
String firstTornPath = null;
@@ -801,7 +804,7 @@ private static void sanitizeSealedResidue(ObjList<MmapSegment> chain, boolean fa
801804
}
802805
}
803806
if (failClosedOnSight && firstTornPath != null) {
804-
throw new SfRecoveryException("corrupt torn tail in sealed SF segment " + firstTornPath);
807+
throw new SfSanitizedResidueException("corrupt torn tail in sealed SF segment " + firstTornPath);
805808
}
806809
}
807810

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,14 @@
2929
* segment chain is corrupt or incomplete and requires operator intervention.
3030
* Operational filesystem failures continue to use {@link MmapSegmentException}
3131
* so callers can retry them without quarantining otherwise recoverable data.
32+
* <p>
33+
* One refinement is deliberately non-terminal for unattended callers:
34+
* {@link SfSanitizedResidueException} marks a first-sight failure thrown
35+
* AFTER recovery durably healed the chain, so a single retry validates
36+
* clean. Catch sites that quarantine on this type must intercept the
37+
* refinement first.
3238
*/
33-
public final class SfRecoveryException extends MmapSegmentException {
39+
public class SfRecoveryException extends MmapSegmentException {
3440

3541
public SfRecoveryException(String message) {
3642
super(message);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*+*****************************************************************************
2+
* ___ _ ____ ____
3+
* / _ \ _ _ ___ ___| |_| _ \| __ )
4+
* | | | | | | |/ _ \/ __| __| | | | _ \
5+
* | |_| | |_| | __/\__ \ |_| |_| | |_) |
6+
* \__\_\\__,_|\___||___/\__|____/|____/
7+
*
8+
* Copyright (c) 2014-2019 Appsicle
9+
* Copyright (c) 2019-2026 QuestDB
10+
*
11+
* Licensed under the Apache License, Version 2.0 (the "License");
12+
* you may not use this file except in compliance with the License.
13+
* You may obtain a copy of the License at
14+
*
15+
* http://www.apache.org/licenses/LICENSE-2.0
16+
*
17+
* Unless required by applicable law or agreed to in writing, software
18+
* distributed under the License is distributed on an "AS IS" BASIS,
19+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
20+
* See the License for the specific language governing permissions and
21+
* limitations under the License.
22+
*
23+
******************************************************************************/
24+
25+
package io.questdb.client.cutlass.qwp.client.sf.cursor;
26+
27+
/**
28+
* First-sight failure over a chain that recovery has <b>already healed</b>.
29+
* Thrown from the fail-closed branch of the sealed-residue sanitize: frame
30+
* accounting was proven complete (contiguity plus boundary checks), the
31+
* proven-dead suffix residue was durably zeroed ({@code msync} +
32+
* {@code fsync} completed — a sync failure throws
33+
* {@link MmapSegmentException} instead and never reaches this type), and
34+
* the throw exists solely to surface the incident on the startup that
35+
* observed it. An immediate re-open re-runs the same proofs over the
36+
* zeroed suffix and succeeds.
37+
* <p>
38+
* Attended callers (producer startup, where an operator or supervisor
39+
* restarts the process) should keep the parent's fail-closed semantics:
40+
* the restart proves the chain clean. Unattended callers (the orphan
41+
* drainer) may retry construction once instead of quarantining — dropping
42+
* a {@code .failed} sentinel over a just-healed slot would strand its
43+
* replayable backlog until an operator clears the sentinel by hand.
44+
*/
45+
public final class SfSanitizedResidueException extends SfRecoveryException {
46+
47+
public SfSanitizedResidueException(String message) {
48+
super(message);
49+
}
50+
}

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import io.questdb.client.cutlass.qwp.client.sf.cursor.AckWatermark;
2828
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
2929
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorSendEngine;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
3031
import io.questdb.client.cutlass.qwp.client.sf.cursor.OrphanScanner;
3132
import io.questdb.client.std.Files;
3233
import io.questdb.client.std.MemoryTag;
@@ -196,6 +197,116 @@ public void testCorruptRecoveredChainIsQuarantined() throws Exception {
196197
});
197198
}
198199

200+
@Test
201+
public void testSealedResidueFirstSightHealsAndDoesNotQuarantine() throws Exception {
202+
TestUtils.assertMemoryLeak(() -> {
203+
// A sealed member whose frame accounting is complete but whose
204+
// suffix gap carries legacy pre-sanitization poison. Recovery
205+
// durably zeroes the residue BEFORE failing closed on first
206+
// sight (SegmentRing's sanitize-then-throw), so at the instant
207+
// the drainer sees the throw the chain on disk is already
208+
// healed. The drainer must retry construction over the healed
209+
// chain and reach connect -- not strand the replayable backlog
210+
// behind a .failed sentinel that no orphan scan revisits.
211+
long segSize = MmapSegment.HEADER_SIZE
212+
+ 4 * (MmapSegment.FRAME_HEADER_SIZE + 16)
213+
+ 12; // sealed suffix gap that can never fit a frame
214+
String p0Path = slotPath + "/p0.sfa";
215+
String p1Path = slotPath + "/p1.sfa";
216+
long gapStart;
217+
long buf = Unsafe.malloc(16, MemoryTag.NATIVE_DEFAULT);
218+
try {
219+
Unsafe.getUnsafe().setMemory(buf, 16, (byte) 5);
220+
MmapSegment s0 = MmapSegment.create(p0Path, 0, segSize);
221+
for (int i = 0; i < 4; i++) {
222+
s0.tryAppend(buf, 16);
223+
}
224+
gapStart = s0.publishedOffset();
225+
s0.close();
226+
MmapSegment s1 = MmapSegment.create(p1Path, 4, segSize);
227+
s1.tryAppend(buf, 16);
228+
s1.close();
229+
} finally {
230+
Unsafe.free(buf, 16, MemoryTag.NATIVE_DEFAULT);
231+
}
232+
233+
// Adopt the chain once so the slot carries a manifest, lock and
234+
// watermark like any real producer slot (without a manifest the
235+
// legacy-migration path sanitizes silently and never fails
236+
// closed); close releases the lock.
237+
try (CursorSendEngine seed = new CursorSendEngine(slotPath, segSize)) {
238+
Assert.assertEquals("highest published FSN must cover frames 0..4",
239+
4L, seed.publishedFsn());
240+
}
241+
242+
// Poison the sealed suffix gap the way a pre-sanitization
243+
// client's reseal-after-recovery did.
244+
int fd = Files.openRW(p0Path);
245+
Assert.assertTrue("openRW must succeed", fd >= 0);
246+
long junk = Unsafe.malloc(12, MemoryTag.NATIVE_DEFAULT);
247+
try {
248+
for (int i = 0; i < 3; i++) {
249+
Unsafe.getUnsafe().putInt(junk + i * 4L, 0xCAFEBABE);
250+
}
251+
Assert.assertEquals(12L, Files.write(fd, junk, 12, gapStart));
252+
Files.fsync(fd);
253+
} finally {
254+
Unsafe.free(junk, 12, MemoryTag.NATIVE_DEFAULT);
255+
Files.close(fd);
256+
}
257+
258+
LinkageError injected = new LinkageError("injected drainer connect error");
259+
AtomicInteger connectAttempts = new AtomicInteger();
260+
BackgroundDrainer drainer = new BackgroundDrainer(
261+
slotPath,
262+
segSize,
263+
Long.MAX_VALUE,
264+
() -> {
265+
connectAttempts.incrementAndGet();
266+
throw injected;
267+
},
268+
5_000L,
269+
1L,
270+
10L,
271+
true,
272+
200L);
273+
274+
LinkageError thrown = null;
275+
try {
276+
drainer.run();
277+
} catch (LinkageError e) {
278+
thrown = e;
279+
}
280+
281+
// The heal precedes the first-sight throw: frames intact,
282+
// residue durably zeroed. This holds with or without the
283+
// drainer fix -- it is exactly what makes quarantine wrong.
284+
try (MmapSegment sealed = MmapSegment.openExisting(p0Path)) {
285+
Assert.assertEquals("frames must be untouched", 4L, sealed.frameCount());
286+
Assert.assertEquals("proven-dead residue must be zeroed on disk",
287+
0L, sealed.tornTailBytes());
288+
}
289+
290+
Assert.assertSame("drainer must reach connect over the healed chain",
291+
injected, thrown);
292+
Assert.assertEquals("exactly one connect attempt after the healed retry",
293+
1, connectAttempts.get());
294+
TestUtils.assertContains(drainer.getLastErrorMessage(),
295+
"injected drainer connect error");
296+
Assert.assertEquals(BackgroundDrainer.DrainOutcome.FAILED, drainer.outcome());
297+
Assert.assertFalse("a just-healed slot must not be quarantined",
298+
Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
299+
Assert.assertTrue("replayable backlog must remain scanner-eligible",
300+
OrphanScanner.isCandidateOrphan(slotPath));
301+
try (CursorSendEngine engine = new CursorSendEngine(slotPath, segSize)) {
302+
Assert.assertEquals("backlog must remain fully replayable",
303+
4L, engine.publishedFsn());
304+
Assert.assertTrue("drainer teardown must release the slot lock",
305+
OrphanScanner.isCandidateOrphan(slotPath));
306+
}
307+
});
308+
}
309+
199310
@Test
200311
public void testLockOpenFailureDoesNotQuarantineRecoverableData() throws Exception {
201312
TestUtils.assertMemoryLeak(() -> {

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegment;
2828
import io.questdb.client.cutlass.qwp.client.sf.cursor.MmapSegmentException;
2929
import io.questdb.client.cutlass.qwp.client.sf.cursor.SegmentRing;
30+
import io.questdb.client.cutlass.qwp.client.sf.cursor.SfSanitizedResidueException;
3031
import io.questdb.client.std.Files;
3132
import io.questdb.client.std.MemoryTag;
3233
import io.questdb.client.std.Misc;
@@ -515,6 +516,9 @@ public void testProvenDeadSealedResidueSanitizedThenHealsAfterOneRestart() throw
515516
Misc.free(SegmentRing.openExisting(tmpDir, segSize));
516517
throw new AssertionError("poisoned sealed suffix must fail closed on first sight");
517518
} catch (MmapSegmentException expected) {
519+
assertTrue("first-sight throw must be the healed-residue refinement so "
520+
+ "unattended callers can retry: " + expected.getClass().getName(),
521+
expected instanceof SfSanitizedResidueException);
518522
assertTrue(expected.getMessage(),
519523
expected.getMessage().contains("corrupt torn tail in sealed SF segment"));
520524
}

0 commit comments

Comments
 (0)