Skip to content

Commit 76fe4f1

Browse files
glasstigerclaude
andcommitted
Bound recovered symbol id to committed frames
CursorSendEngine.recoveredMaxSymbolId walked every recovered frame, including the aborted orphan-deferred tail -- the frames trySendOne retires without ever transmitting. Those frames typically introduce the highest symbol ids, so a host crash that tore the persisted .symbol-dict down to the committed ids while an orphan-tail frame referenced a higher one inflated recoveredMaxSymbolId and made seedGlobalDictionaryFromPersisted reject an otherwise fully recoverable slot. The producer never reuses an orphan id on the wire -- the tail retires before its new frames send -- so the rejection was a false positive. Bound the maxSymbolDeltaEnd walk to recoveredCommitBoundaryFsn so only committed frames count. MmapSegment and SegmentRing take a maxFsnInclusive argument; a frame whose fsn (baseSeq + i) exceeds it is skipped, and the walk still advances past it. A slot with no orphan tail is unchanged: the boundary equals publishedFsn, so every frame still counts. Add testRecoveredMaxSymbolIdExcludesOrphanTailFrames: a committed delta frame registering ids 0,1 plus a deferred (orphan) frame registering id 2 must recover recoveredMaxSymbolId == 1, not 2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2ad8f50 commit 76fe4f1

4 files changed

Lines changed: 94 additions & 24 deletions

File tree

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

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -321,18 +321,22 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
321321
publishedFsn - Math.max(recoveredCommitBoundaryFsn, -1L),
322322
recoveredCommitBoundaryFsn, publishedFsn);
323323
}
324-
// Highest symbol id the surviving frames reference. A resuming
325-
// producer compares this against its recovered dictionary size
326-
// (seedGlobalDictionaryFromPersisted) to detect a host-crash tear:
327-
// if a frame references an id the (unsynced, torn) .symbol-dict no
328-
// longer holds, resuming would re-use it. maxSymbolDeltaEnd returns
329-
// 0 when no frame carries a symbol, yielding -1 here. Computed
330-
// before the I/O loop or producer append; single-threaded here.
324+
// Highest symbol id the surviving COMMITTED frames reference. A
325+
// resuming producer compares this against its recovered dictionary
326+
// size (seedGlobalDictionaryFromPersisted) to detect a host-crash
327+
// tear: if a committed frame references an id the (unsynced, torn)
328+
// .symbol-dict no longer holds, resuming would re-use it. The walk is
329+
// bounded to recoveredCommitBoundaryFsn so the aborted orphan-deferred
330+
// tail -- retired without ever being transmitted -- does not inflate
331+
// this and over-reject an otherwise-recoverable slot. maxSymbolDeltaEnd
332+
// returns 0 when no such frame carries a symbol, yielding -1 here.
333+
// Computed before the I/O loop or producer append; single-threaded.
331334
this.recoveredMaxSymbolId = recovered.maxSymbolDeltaEnd(
332335
io.questdb.client.cutlass.qwp.protocol.QwpConstants.MAGIC_MESSAGE,
333336
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_OFFSET_FLAGS,
334337
io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DELTA_SYMBOL_DICT,
335-
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE) - 1L;
338+
io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE,
339+
recoveredCommitBoundaryFsn) - 1L;
336340
} else {
337341
// Fresh start with no recovered segments. Any stale
338342
// watermark from a prior fully-drained session refers

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

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -530,23 +530,34 @@ public long findLastFrameFsnWithoutPayloadFlag(int flagsOffset, int flagMask, in
530530
* out-of-order page loss) below the ids the surviving frames still reference: if
531531
* the max here reaches at or beyond the recovered dictionary size, a resuming
532532
* producer -- seeded from the shorter dictionary -- would re-use ids the frames
533-
* already define. The frame layout mirrors {@link #findLastFrameFsnWithoutPayloadFlag}:
534-
* the QWP message header ({@code qwpHeaderSize} bytes) is followed by the delta
535-
* section {@code [deltaStart varint][deltaCount varint]...}.
533+
* already define. Only frames at or below {@code maxFsnInclusive} count: frames
534+
* above it are the aborted orphan-deferred tail, which {@code trySendOne} retires
535+
* without ever transmitting, so their ids must not inflate the result (a resuming
536+
* producer never reuses them on the wire). The frame layout mirrors
537+
* {@link #findLastFrameFsnWithoutPayloadFlag}: the QWP message header
538+
* ({@code qwpHeaderSize} bytes) is followed by the delta section
539+
* {@code [deltaStart varint][deltaCount varint]...}.
536540
*
537-
* @param headerMagic the QWP message magic identifying a well-formed frame
538-
* @param flagsOffset byte offset of the flags field within the QWP header
539-
* @param flagDeltaMask the FLAG_DELTA_SYMBOL_DICT bit
540-
* @param qwpHeaderSize the QWP message header size (delta section starts past it)
541+
* @param headerMagic the QWP message magic identifying a well-formed frame
542+
* @param flagsOffset byte offset of the flags field within the QWP header
543+
* @param flagDeltaMask the FLAG_DELTA_SYMBOL_DICT bit
544+
* @param qwpHeaderSize the QWP message header size (delta section starts past it)
545+
* @param maxFsnInclusive highest frame FSN to consider; frames above it are the
546+
* retired orphan-deferred tail -- pass
547+
* {@code recoveredCommitBoundaryFsn}
541548
*/
542-
public long maxSymbolDeltaEnd(int headerMagic, int flagsOffset, int flagDeltaMask, int qwpHeaderSize) {
549+
public long maxSymbolDeltaEnd(int headerMagic, int flagsOffset, int flagDeltaMask, int qwpHeaderSize, long maxFsnInclusive) {
543550
long maxEnd = 0L;
544551
long off = HEADER_SIZE;
545552
long frames = frameCount;
546553
for (long i = 0; i < frames; i++) {
554+
long fsn = baseSeq + i;
547555
int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + off + 4);
548556
long payload = mmapAddress + off + FRAME_HEADER_SIZE;
549-
if (payloadLen >= qwpHeaderSize
557+
// Skip the orphan-deferred tail (fsn > maxFsnInclusive): retired, never
558+
// sent, so its ids must not count. off still advances below.
559+
if (fsn <= maxFsnInclusive
560+
&& payloadLen >= qwpHeaderSize
550561
&& payloadLen > flagsOffset
551562
&& Unsafe.getUnsafe().getInt(payload) == headerMagic
552563
&& (Unsafe.getUnsafe().getByte(payload + flagsOffset) & flagDeltaMask) != 0) {

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

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -547,20 +547,22 @@ public synchronized long findLastFsnWithoutPayloadFlag(int flagsOffset, int flag
547547
}
548548

549549
/**
550-
* Highest {@code deltaStart + deltaCount} any symbol-dict delta frame in the
551-
* ring references (0 when none). See {@link MmapSegment#maxSymbolDeltaEnd};
552-
* used once at recovery to detect a persisted dictionary torn below the ids
553-
* the surviving frames reference.
550+
* Highest {@code deltaStart + deltaCount} any symbol-dict delta frame at or below
551+
* {@code maxFsnInclusive} references (0 when none). See
552+
* {@link MmapSegment#maxSymbolDeltaEnd}; used once at recovery to detect a
553+
* persisted dictionary torn below the ids the surviving committed frames
554+
* reference. Frames above {@code maxFsnInclusive} are the retired orphan-deferred
555+
* tail and are excluded.
554556
*/
555-
public synchronized long maxSymbolDeltaEnd(int headerMagic, int flagsOffset, int flagDeltaMask, int qwpHeaderSize) {
557+
public synchronized long maxSymbolDeltaEnd(int headerMagic, int flagsOffset, int flagDeltaMask, int qwpHeaderSize, long maxFsnInclusive) {
556558
long maxEnd = 0L;
557559
for (int i = 0, n = sealedSegments.size(); i < n; i++) {
558-
long end = sealedSegments.get(i).maxSymbolDeltaEnd(headerMagic, flagsOffset, flagDeltaMask, qwpHeaderSize);
560+
long end = sealedSegments.get(i).maxSymbolDeltaEnd(headerMagic, flagsOffset, flagDeltaMask, qwpHeaderSize, maxFsnInclusive);
559561
if (end > maxEnd) {
560562
maxEnd = end;
561563
}
562564
}
563-
long end = active.maxSymbolDeltaEnd(headerMagic, flagsOffset, flagDeltaMask, qwpHeaderSize);
565+
long end = active.maxSymbolDeltaEnd(headerMagic, flagsOffset, flagDeltaMask, qwpHeaderSize, maxFsnInclusive);
564566
return Math.max(maxEnd, end);
565567
}
566568

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@
7575
public class CursorWebSocketSendLoopOrphanTailTest {
7676

7777
private static final int FLAG_DEFER_COMMIT = 0x01;
78+
private static final int FLAG_DELTA_SYMBOL_DICT = 0x08;
7879
private static final int HEADER_OFFSET_FLAGS = 5;
80+
private static final int HEADER_SIZE = 12;
7981
private static final int MAGIC_MESSAGE = 0x31505751; // "QWP1" little-endian
8082

8183
private String tmpDir;
@@ -221,6 +223,36 @@ public void testSlowPathReplaysBelowTailThenRetiresAndRecyclesOnce() throws Exce
221223
});
222224
}
223225

226+
@Test
227+
public void testRecoveredMaxSymbolIdExcludesOrphanTailFrames() throws Exception {
228+
// recoveredMaxSymbolId must reflect only COMMITTED (transmitted) frames, not
229+
// the aborted orphan-tail frames trySendOne retires without ever sending. A
230+
// host crash that tears the persisted dictionary down to the committed ids
231+
// while an orphan-tail frame introduced a HIGHER id must NOT over-reject the
232+
// resume: the producer never reuses an orphan id on the wire (the tail retires
233+
// first), so counting it would inflate recoveredMaxSymbolId and make
234+
// seedGlobalDictionaryFromPersisted fail a fully-recoverable slot. The
235+
// maxSymbolDeltaEnd walk is therefore bounded to recoveredCommitBoundaryFsn.
236+
TestUtils.assertMemoryLeak(() -> {
237+
try (CursorSendEngine engine = newEngine()) {
238+
// fsn 0: commit-bearing delta frame registering ids 0,1.
239+
appendDeltaFrame(engine, false, 0, 2);
240+
// fsn 1: DEFERRED delta frame registering id 2 -- the orphan tail.
241+
appendDeltaFrame(engine, true, 2, 1);
242+
}
243+
try (CursorSendEngine engine = newEngine()) {
244+
assertTrue(engine.wasRecoveredFromDisk());
245+
assertEquals("last commit-bearing frame", 0L, engine.recoveredCommitBoundaryFsn());
246+
assertEquals("orphan tail tip", 1L, engine.recoveredOrphanTipFsn());
247+
// Only the committed frame's ids (0,1) count -> highest id 1. The
248+
// orphan-tail frame's id 2 is excluded, so a resume whose recovered
249+
// dictionary holds ids 0,1 (size 2) is NOT over-rejected.
250+
assertEquals("orphan-tail id 2 must be excluded from recoveredMaxSymbolId",
251+
1L, engine.recoveredMaxSymbolId());
252+
}
253+
});
254+
}
255+
224256
// ---------------------------------------------------------------------
225257
// harness
226258
// ---------------------------------------------------------------------
@@ -291,6 +323,27 @@ private static void appendFrame(CursorSendEngine engine, boolean defer) {
291323
}
292324
}
293325

326+
// Appends a QWP frame carrying a symbol-dict delta section ([deltaStart varint]
327+
// [deltaCount varint]) so the recovery walk's maxSymbolDeltaEnd counts it.
328+
// deltaStart/deltaCount stay < 128 so each encodes in a single LEB128 byte.
329+
private static void appendDeltaFrame(CursorSendEngine engine, boolean defer, int deltaStart, int deltaCount) {
330+
int size = HEADER_SIZE + 2;
331+
long buf = Unsafe.malloc(size, MemoryTag.NATIVE_DEFAULT);
332+
try {
333+
for (int i = 0; i < size; i++) {
334+
Unsafe.getUnsafe().putByte(buf + i, (byte) 0);
335+
}
336+
Unsafe.getUnsafe().putInt(buf, MAGIC_MESSAGE);
337+
Unsafe.getUnsafe().putByte(buf + HEADER_OFFSET_FLAGS,
338+
(byte) (FLAG_DELTA_SYMBOL_DICT | (defer ? FLAG_DEFER_COMMIT : 0)));
339+
Unsafe.getUnsafe().putByte(buf + HEADER_SIZE, (byte) deltaStart);
340+
Unsafe.getUnsafe().putByte(buf + HEADER_SIZE + 1, (byte) deltaCount);
341+
engine.appendBlocking(buf, size);
342+
} finally {
343+
Unsafe.free(buf, size, MemoryTag.NATIVE_DEFAULT);
344+
}
345+
}
346+
294347
private static void awaitAckedFsn(CursorSendEngine engine, long target) throws InterruptedException {
295348
long deadline = System.nanoTime() + 10_000_000_000L;
296349
while (engine.ackedFsn() < target) {

0 commit comments

Comments
 (0)