Skip to content

Commit 3f98352

Browse files
glasstigerclaude
andcommitted
Close the remaining four correctness holes in SF recovery
C5. The foreground send loop CONSUMED the engine's recovery sources -- pd.takeLoadedEntries() plus engine.releaseRecoveredSymbolStorage() -- making the hand-off one-shot while leaving a second construction reachable: ensureConnected's catch closes and nulls cursorSendLoop precisely so a caller can retry, and `connected` is set only at the very end. Neither call cleared the counts that gate re-seeding, so the retry saw recoveredSize() > 0 with loadedEntriesLen() == 0, left sentDictCount at 0, and then either tripped the baseline mismatch or latched the torn-dictionary terminal on a healthy slot. Both policies now BORROW, as the orphan drainer already did; the engine frees at close. Lifetime holds in both directions because QwpWebSocketSender.close() and BackgroundDrainer's finally both close the loop before the engine. The two transfer methods had no other caller and are deleted. C6. A pre-21 unsafe-access fault is delivered asynchronously, so one taken while scanning a segment can surface at MmapSegment.openExisting's RETURN -- in SegmentRing's frame, before `seg = openExisting(...)` ever assigns. The segment was fully constructed, so its fd and whole-file mapping leaked with nothing referencing them, while the log claimed a skip that had not happened. openExisting now publishes the segment into a caller-supplied holder immediately before returning, and SegmentRing's finally closes whichever handle survived. The holder is cleared at every ownership transfer, so it can never close a segment `opened` now holds. C7. accept() checkpoints committedRawLen/Count only at a commit-bearing frame, while foldDelta's gap-reset rewinds runningRawLen/Count to 0 without touching them -- so the next appendRaw overwrites the committed bytes in place. Harmless when a commit-bearing frame follows, but a reset inside an uncommitted deferred tail never gets that refresh and finish() then published the old counts over the tail's bytes: recovery decoded the tail's symbol under the committed id while the frame that actually replays registered a different one. finish() now detects the rewind and fails clean instead. C8. markFailed is best-effort and returns silently when it cannot open the sentinel -- and a full or read-only disk, the condition that makes a slot unreplayable, is exactly the condition that makes writing it fail. Twelve lines after quarantining, the same build() dispatches its orphan drainers, and the quarantined copy was still a candidate: unbounded churn against data explicitly declared human-in-the-loop, repeating on every restart. OrphanScanner now excludes quarantined slots BY NAME, and owns the reserved infix that Sender.build() renames with. Regression tests, each verified to fail with its fix reverted: testForegroundLoopReSeedsAfterAClosedFirstAttempt (UnreplayableSlot "recovery symbol baseline mismatch [expected=2, actual=0]"), testGapResetInsideTheDeferredTailFailsCleanInsteadOfMisattributing (expected:<-1> but was:<1>), and testQuarantinedSlotIsNotACandidateEvenWithoutTheFailedSentinel. The two MirrorLeakTest cases that pinned the old ownership transfer now pin borrowing, including that closing a borrowed loop leaves the engine's buffer alive. C6 has no regression test: reproducing it needs a fault delivered at one specific instruction boundary on a pre-21 JIT, which is the same JIT-dependent delivery the existing MmapSegmentRecoveryFaultTest guards cannot pin either. The change is a strict improvement over having no handle at all, and the existing multi-segment recovery tests cover the double-close risk it introduces. Full client suite: 2693 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 66bdc0f commit 3f98352

11 files changed

Lines changed: 274 additions & 50 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,8 @@ final class LineSenderBuilder {
10261026
// sender's own slot name, so a restarted sender does not re-adopt it as its own;
10271027
// quarantineTornSlot then marks it .failed, so the orphan drainer skips it too and
10281028
// the bytes stay put for a human to inspect and resend.
1029-
private static final String QUARANTINE_SLOT_SUFFIX = ".unreplayable-";
1029+
private static final String QUARANTINE_SLOT_SUFFIX =
1030+
OrphanScanner.QUARANTINE_SLOT_INFIX;
10301031
private final ObjList<String> hosts = new ObjList<>();
10311032
private final IntList ports = new IntList();
10321033
private long authTimeoutMillis = QwpWebSocketSender.DEFAULT_AUTH_TIMEOUT_MS;

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -809,12 +809,6 @@ void copyRecoveredSymbolSuffix(int baseline, long target) {
809809
}
810810
}
811811

812-
void releaseRecoveredSymbolStorage() {
813-
if (recoveredFrameAnalysis != null) {
814-
recoveredFrameAnalysis.releaseRawStorage();
815-
}
816-
}
817-
818812
@TestOnly
819813
public long recoveryFramesVisited() {
820814
return recoveredFrameAnalysis == null ? 0L : recoveredFrameAnalysis.framesVisited();

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

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -725,7 +725,6 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
725725
// tracks. When the dictionary could not be opened, pd is null, the mirror
726726
// simply starts empty and grows from the frames themselves.
727727
PersistedSymbolDict pd = engine.getPersistedSymbolDict();
728-
int persistedPrefixLen = 0;
729728
if (pd != null && pd.recoveredSize() > 0) {
730729
int len = pd.loadedEntriesLen();
731730
if (len > 0) {
@@ -734,7 +733,6 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
734733
// one engine can create several sessions during capability-gap
735734
// recycling. A borrowed mirror performs copy-on-write if a recovered
736735
// frame suffix must extend it.
737-
persistedPrefixLen = len;
738736
sentDictBytesAddr = pd.loadedEntriesAddr();
739737
sentDictBytesCapacity = len;
740738
sentDictBytesLen = len;
@@ -803,24 +801,24 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
803801
releaseSentDictBytes();
804802
throw t;
805803
}
806-
// QwpWebSocketSender seeds the producer dictionary before constructing
807-
// its one foreground loop, so after the loop has built its mirror it can
808-
// retire both engine-owned recovery sources. BackgroundDrainer must retain
809-
// them for later recycled loops and therefore keeps borrowing them.
810-
if (reconnectPolicy == ReconnectPolicy.FOREGROUND) {
811-
if (persistedPrefixLen > 0) {
812-
long persistedPrefixAddr = pd.takeLoadedEntries();
813-
if (sentDictBytesOwned) {
814-
// Copy-on-grow already produced the combined mirror. Retire the
815-
// no-longer-needed persisted prefix after successful construction.
816-
Unsafe.free(persistedPrefixAddr, persistedPrefixLen, MemoryTag.NATIVE_DEFAULT);
817-
} else {
818-
assert persistedPrefixAddr == sentDictBytesAddr;
819-
sentDictBytesOwned = true;
820-
}
821-
}
822-
engine.releaseRecoveredSymbolStorage();
823-
}
804+
// BOTH policies borrow the engine-owned recovery sources; neither consumes them.
805+
//
806+
// The foreground path used to take ownership here -- pd.takeLoadedEntries() plus
807+
// engine.releaseRecoveredSymbolStorage() -- which made the hand-off ONE-SHOT while
808+
// leaving a second construction reachable: QwpWebSocketSender.ensureConnected's
809+
// catch closes and nulls cursorSendLoop precisely so a caller can retry, and
810+
// `connected` is only set at the very end. takeLoadedEntries zeroes addr/len but
811+
// NOT size, and releaseRecoveredSymbolStorage zeroes the raw buffer but NOT
812+
// baseline, so the retry saw pd.recoveredSize() > 0 with loadedEntriesLen() == 0,
813+
// left sentDictCount at 0, and then either tripped checkedRecoveryAnalysis'
814+
// baseline mismatch or -- when recoveredMaxSymbolDeltaStart == 0 -- latched the
815+
// torn-dictionary terminal ("resend required") on a perfectly healthy slot.
816+
//
817+
// Borrowing removes the state machine instead of trying to make it idempotent.
818+
// Lifetime is safe in both directions: QwpWebSocketSender.close() closes the loop
819+
// before the engine, and BackgroundDrainer's finally does the same, so the buffers
820+
// always outlive their borrower. Any growth copy-on-writes into loop-owned memory
821+
// (ensureSentDictCapacity), and releaseSentDictBytes frees only what the loop owns.
824822
this.fsnAtZero = fsnAtZero;
825823
this.parkNanos = parkNanos;
826824
this.reconnectFactory = reconnectFactory;

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,23 @@ public static MmapSegment openExisting(String path) {
335335
* {@link FilesFacade#length(String)}.
336336
*/
337337
public static MmapSegment openExisting(FilesFacade ff, String path) {
338+
return openExisting(ff, path, null);
339+
}
340+
341+
/**
342+
* As {@link #openExisting(FilesFacade, String)}, but also publishes the constructed
343+
* segment into {@code inFlight[0]} immediately before returning it.
344+
* <p>
345+
* Pre-JDK-21 HotSpot delivers an unsafe-access fault ASYNCHRONOUSLY -- at the next
346+
* return or safepoint check rather than at the faulting instruction (JDK-8283699).
347+
* A fault taken while scanning this segment can therefore surface at this method's
348+
* RETURN, in the caller's frame, so the caller's {@code seg = openExisting(...)}
349+
* assignment never happens even though the fd and the whole-file mapping were
350+
* already acquired -- and nothing is left holding them. The holder gives the
351+
* caller's {@code finally} something to close. Callers MUST clear {@code inFlight[0]}
352+
* wherever they transfer ownership, or they will close a segment they just handed on.
353+
*/
354+
static MmapSegment openExisting(FilesFacade ff, String path, MmapSegment[] inFlight) {
338355
long fileSize = ff.length(path);
339356
if (fileSize < HEADER_SIZE) {
340357
throw new MmapSegmentException("file shorter than header: " + path + " size=" + fileSize);
@@ -382,8 +399,12 @@ public static MmapSegment openExisting(FilesFacade ff, String path) {
382399
+ "Investigate disk health or unexpected writer crash.",
383400
path, tornTail, lastGood, fileSize, count);
384401
}
385-
return new MmapSegment(path, fd, addr, fileSize, baseSeq,
402+
MmapSegment segment = new MmapSegment(path, fd, addr, fileSize, baseSeq,
386403
lastGood, count, false, tornTail, version);
404+
if (inFlight != null) {
405+
inFlight[0] = segment;
406+
}
407+
return segment;
387408
} catch (Throwable t) {
388409
if (addr != Files.FAILED_MMAP_ADDRESS) {
389410
Files.munmap(addr, fileSize, MemoryTag.MMAP_DEFAULT);

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,20 @@ public final class OrphanScanner {
6161

6262
/** Name of the sentinel that disqualifies a slot from auto-drain. */
6363
public static final String FAILED_SENTINEL_NAME = ".failed";
64+
/**
65+
* Reserved infix for a slot {@code Sender.build()} set aside as unreplayable. Such a
66+
* copy is kept for forensics and a manual resend; it is NEVER drainable, so the
67+
* scanner excludes it BY NAME.
68+
* <p>
69+
* Excluding it by name rather than by the {@code .failed} sentinel alone is what makes
70+
* the exclusion reliable. {@code markFailed} is best-effort and returns silently when
71+
* it cannot open the sentinel -- and the condition that makes a slot unreplayable (a
72+
* full or read-only disk) is exactly the condition that makes writing the sentinel
73+
* fail. Without the name check, the SAME build() that quarantined the slot dispatches
74+
* its orphan drainers twelve lines later and re-adopts it: unbounded churn against
75+
* data explicitly declared human-in-the-loop, repeating on every restart.
76+
*/
77+
public static final String QUARANTINE_SLOT_INFIX = ".unreplayable-";
6478

6579
private OrphanScanner() {
6680
}
@@ -271,12 +285,29 @@ public static boolean isCandidateOrphan(String slotPath) {
271285
if (!Files.exists(slotPath)) {
272286
return false;
273287
}
288+
if (isQuarantinedSlotName(slotPath)) {
289+
return false;
290+
}
274291
if (Files.exists(slotPath + "/" + FAILED_SENTINEL_NAME)) {
275292
return false;
276293
}
277294
return hasAnySegmentFile(slotPath);
278295
}
279296

297+
/**
298+
* Whether {@code slotPath}'s last path element names a quarantined slot. Independent
299+
* of the {@code .failed} sentinel, which is best-effort -- see
300+
* {@link #QUARANTINE_SLOT_INFIX}.
301+
*/
302+
public static boolean isQuarantinedSlotName(String slotPath) {
303+
if (slotPath == null) {
304+
return false;
305+
}
306+
int slash = Math.max(slotPath.lastIndexOf('/'), slotPath.lastIndexOf('\\'));
307+
String name = slash < 0 ? slotPath : slotPath.substring(slash + 1);
308+
return name.contains(QUARANTINE_SLOT_INFIX);
309+
}
310+
280311
/**
281312
* Drops a {@link #FAILED_SENTINEL_NAME} file in {@code slotPath}.
282313
* Idempotent — touching an existing sentinel is a no-op (its presence

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

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -567,18 +567,6 @@ public int loadedEntriesLen() {
567567
return loadedEntriesLen;
568568
}
569569

570-
/**
571-
* Transfers the recovered entry buffer to the foreground send loop. The
572-
* symbol count remains unchanged because it is also the append baseline;
573-
* only the native-buffer ownership moves. Construction-phase only.
574-
*/
575-
synchronized long takeLoadedEntries() {
576-
long addr = loadedEntriesAddr;
577-
loadedEntriesAddr = 0L;
578-
loadedEntriesLen = 0;
579-
return addr;
580-
}
581-
582570
/**
583571
* Number of symbols {@link #open} recovered from disk -- the exact entry count of
584572
* {@link #loadedEntriesAddr()} / {@link #loadedEntriesLen()}. Unlike {@link #size()}

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ final class RecoveredFrameAnalysis implements QuietCloseable {
5151
private int committedRawCount;
5252
private int committedRawLen;
5353
private long framesVisited;
54+
// Set when a gap-reset rewinds the raw write cursor, cleared at every commit
55+
// checkpoint. Still set at finish() => the committed snapshot's bytes were
56+
// overwritten by an uncommitted tail and must not be published. See finish().
57+
private boolean hasRewoundSinceCommit;
5458
private boolean runningGap;
5559
private boolean runningUnackedGap;
5660
private long runningMaxDeltaEnd;
@@ -92,6 +96,7 @@ void accept(long fsn, long payload, int payloadLen) {
9296
committedMaxDeltaStart = runningMaxDeltaStart;
9397
committedRawLen = runningRawLen;
9498
committedRawCount = runningRawCount;
99+
hasRewoundSinceCommit = false;
95100
}
96101
}
97102

@@ -142,6 +147,18 @@ long framesVisited() {
142147
* deferred tail. Call exactly once after the ordered recovery walk.
143148
*/
144149
void finish() {
150+
if (hasRewoundSinceCommit) {
151+
// A gap-reset rewound the write cursor after the last commit checkpoint, so
152+
// the deferred tail has overwritten the bytes committedRawLen/Count still
153+
// describe. Publishing them would decode the TAIL's entries under the
154+
// committed ids -- exactly the silent misattribution this analysis exists to
155+
// prevent. Fail clean instead: no suffix, and a gap so coverage() reports -1
156+
// and the producer falls back to the persisted prefix (or, when unacked
157+
// frames depend on the missing ids, quarantines).
158+
committedRawLen = 0;
159+
committedRawCount = 0;
160+
committedGap = true;
161+
}
145162
runningRawLen = committedRawLen;
146163
runningRawCount = committedRawCount;
147164
if (rawCapacity == committedRawLen) {
@@ -274,6 +291,13 @@ private void foldDelta(long fsn, long p, long limit) {
274291
}
275292
runningGap = false;
276293
runningCoverage = baseline;
294+
// Rewinding the write cursor to 0 means the NEXT appendRaw overwrites
295+
// [0, committedRawLen) in place -- bytes the committed snapshot still
296+
// counts. That is harmless when a commit-bearing frame follows (accept()
297+
// re-checkpoints and clears this flag), but a reset inside an uncommitted
298+
// deferred TAIL never gets that refresh, and finish() would then hand out
299+
// the old counts over the tail's bytes: silent symbol misattribution.
300+
hasRewoundSinceCommit = true;
277301
runningRawLen = 0;
278302
runningRawCount = 0;
279303
}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,15 +205,20 @@ public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxByt
205205
try {
206206
try {
207207
int rc = 1;
208+
// Reused across files: MmapSegment.openExisting publishes the segment
209+
// here before returning, so a pre-21 late-delivered unsafe-access fault
210+
// that steals the return value still leaves us something to close.
211+
final MmapSegment[] inFlight = new MmapSegment[1];
208212
while (rc > 0) {
209213
String name = Files.utf8ToString(Files.findName(find));
210214
if (name != null
211215
&& name.endsWith(".sfa")
212216
&& !isLegacyReaderGuard(name)) {
213217
String path = sfDir + "/" + name;
214218
MmapSegment seg = null;
219+
inFlight[0] = null;
215220
try {
216-
seg = MmapSegment.openExisting(ff, path);
221+
seg = MmapSegment.openExisting(ff, path, inFlight);
217222
// Filter out empty leftovers -- typically hot-spare
218223
// segments the manager pre-allocated for a prior
219224
// session that never got rotated into active. They
@@ -238,6 +243,7 @@ public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxByt
238243
long torn = seg.tornTailBytes();
239244
seg.close();
240245
seg = null;
246+
inFlight[0] = null;
241247
if (torn > 0) {
242248
Files.rename(path, path + ".corrupt");
243249
} else {
@@ -246,6 +252,7 @@ public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxByt
246252
} else {
247253
opened.add(seg);
248254
seg = null;
255+
inFlight[0] = null;
249256
}
250257
} catch (Throwable t) {
251258
// Per-file data error (bad magic, bad header,
@@ -285,9 +292,16 @@ public static SegmentRing openExisting(FilesFacade ff, String sfDir, long maxByt
285292
// open and transfer -- most importantly an OOM
286293
// from ObjList.add growing its backing array
287294
// after the mmap+fd were already acquired.
288-
if (seg != null) {
295+
// seg may still be null while the segment was fully
296+
// constructed: a late-delivered mmap fault can surface at
297+
// openExisting's RETURN, before the assignment. inFlight is
298+
// cleared at every ownership transfer above, so this can
299+
// never close a segment `opened` now holds.
300+
MmapSegment orphan = seg != null ? seg : inFlight[0];
301+
inFlight[0] = null;
302+
if (orphan != null) {
289303
try {
290-
seg.close();
304+
orphan.close();
291305
} catch (Throwable closeErr) {
292306
LOG.warn("openExisting: error closing in-flight segment {}",
293307
path, closeErr);

0 commit comments

Comments
 (0)