Skip to content

Commit 2fa5cd9

Browse files
glasstigerclaude
andcommitted
Recover full-dict SF slots without bricking build
A store-and-forward slot written in full-dict fallback -- the .symbol-dict could not open when writing, so every frame re-ships the whole dictionary from id 0 -- leaves self-sufficient frames behind but no side-file. On recovery the engine opens a fresh empty .symbol-dict, so the surviving frames out-reach it, and the sender's seed-time guard mistook the empty dictionary for a host-crash tear: it failed Sender.build() for a slot the orphan drainer drains fine. CursorSendEngine now tells the two cases apart with a new maxSymbolDeltaStart walk (MmapSegment, SegmentRing). When the persisted dictionary is a subset of the ids the frames reference but every such frame is self-sufficient (maxSymbolDeltaStart == 0), the engine discards the empty side-file so isDeltaDictEnabled() reports false and the slot recovers in full-dict mode, exactly how it was written. A genuine torn delta dictionary keeps a deltaStart > 0 frame and still fails clean. testFullDictFramesRecoverInFullDictModeInsteadOfBricking covers the new path -- red without the discard, green with it. The torn-delta fail-clean tests are unchanged. Also regroups CursorWebSocketSendLoop's six new mutable delta-dict fields with the other mutable fields (deltaDictEnabled stays final, in its alphabetical slot) and adds the missing thousands separator to a test's 16_384 batch-size literal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 265b703 commit 2fa5cd9

7 files changed

Lines changed: 252 additions & 54 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3900,14 +3900,23 @@ private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
39003900
return;
39013901
}
39023902
// Run the torn-dictionary guard BEFORE the empty-dictionary short-circuit
3903-
// below: a TOTAL tear (pd.size() == 0) with surviving symbol-bearing frames
3904-
// must fail clean too, not slip through. Such a frame starts at deltaStart=0
3905-
// and self-heals the I/O-thread catch-up mirror, so the send loop's replay
3906-
// guard (deltaStart > sentDictCount) never fires -- this seed-time guard is
3907-
// then the only defense against the producer resuming unseeded and silently
3908-
// reusing ids the frames already define. A genuinely empty slot (no
3909-
// symbol-bearing frames) has recoveredMaxSymbolId() == -1, so -1 >= 0 is false
3910-
// and the pd.size() == 0 return below still fires.
3903+
// below: a TOTAL tear (pd.size() == 0) of a DELTA dictionary with surviving
3904+
// symbol-bearing frames must fail clean too, not slip through. Such a frame
3905+
// starts at deltaStart=0 and self-heals the I/O-thread catch-up mirror, so the
3906+
// send loop's replay guard (deltaStart > sentDictCount) never fires -- this
3907+
// seed-time guard is then the only defense against the producer resuming
3908+
// unseeded and silently reusing ids the frames already define. A genuinely
3909+
// empty slot (no symbol-bearing frames) has recoveredMaxSymbolId() == -1, so
3910+
// -1 >= 0 is false and the pd.size() == 0 return below still fires.
3911+
//
3912+
// This guard fires ONLY for a torn DELTA dictionary. The other way the frames
3913+
// can out-reach the recovered dictionary -- a slot written in FULL-DICT
3914+
// fallback (the dictionary never opened when writing, every frame
3915+
// self-sufficient at deltaStart=0), then recovered against a fresh empty one --
3916+
// is caught upstream in CursorSendEngine, which discards the empty side-file so
3917+
// isDeltaDictEnabled() is false and this seed never runs. Those frames need no
3918+
// dictionary; failing clean here would needlessly brick build() for a slot the
3919+
// orphan drainer drains fine.
39113920
if (cursorEngine != null && cursorEngine.recoveredMaxSymbolId() >= pd.size()) {
39123921
throw new LineSenderException(
39133922
"recovered store-and-forward symbol dictionary is a subset of the surviving frames "

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,36 @@ private CursorSendEngine(String sfDir, long segmentSizeBytes, SegmentManager man
338338
QwpConstants.FLAG_DELTA_SYMBOL_DICT,
339339
QwpConstants.HEADER_SIZE,
340340
recoveredCommitBoundaryFsn) - 1L;
341+
// Full-dict-fallback recovery. When the persisted .symbol-dict is a
342+
// SUBSET of the ids the surviving frames reference
343+
// (recoveredMaxSymbolId >= its size) YET every such frame is
344+
// self-sufficient (maxSymbolDeltaStart == 0 -- a full-dict frame that
345+
// re-registers its dictionary from id 0), the slot was written in
346+
// full-dict fallback: the dictionary never opened when writing, so no
347+
// side-file exists and this recovery opened a FRESH EMPTY one. Those
348+
// frames replay with no dictionary, so discard the empty side-file and
349+
// recover in full-dict mode -- isDeltaDictEnabled() then reports false
350+
// and the producer + send loop both run full-dict, exactly as the slot
351+
// was written. Without this the sender's seed-time guard would treat the
352+
// empty dictionary as a host-crash tear and brick build(), even though
353+
// the orphan drainer drains the same frames fine. A genuine torn DELTA
354+
// dictionary keeps a frame with deltaStart > 0 (maxSymbolDeltaStart > 0)
355+
// and is NOT discarded here: it still fails clean at seed time, since
356+
// the ids its delta frames reference cannot be rebuilt without the lost
357+
// dictionary. The recoveredMaxSymbolId >= size guard means this never
358+
// fires for a slot whose dictionary is intact, nor for an empty slot
359+
// (recoveredMaxSymbolId == -1). Single-threaded; before the I/O loop.
360+
if (persistedDictInProgress != null
361+
&& recoveredMaxSymbolId >= persistedDictInProgress.size()
362+
&& recovered.maxSymbolDeltaStart(
363+
QwpConstants.MAGIC_MESSAGE,
364+
QwpConstants.HEADER_OFFSET_FLAGS,
365+
QwpConstants.FLAG_DELTA_SYMBOL_DICT,
366+
QwpConstants.HEADER_SIZE,
367+
recoveredCommitBoundaryFsn) == 0L) {
368+
persistedDictInProgress.close();
369+
persistedDictInProgress = null;
370+
}
341371
} else {
342372
// Fresh start with no recovered segments. Any stale
343373
// watermark from a prior fully-drained session refers

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

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
168168
* Throttle "reconnect attempt N failed" WARN logs to one per 5 s.
169169
*/
170170
private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L;
171+
// True when this loop delta-encodes symbol dictionaries: it keeps a reconnect
172+
// catch-up mirror (sentDict*) and re-registers the whole dictionary on a fresh
173+
// server before replaying delta frames. See the sentDict* fields in the mutable
174+
// section for the full mechanism. Gates all the delta-dict state.
175+
private final boolean deltaDictEnabled;
171176
// Pre-converted to nanos for the comparison in sendDurableAckKeepaliveIfDue.
172177
// Zero or negative disables the keepalive entirely.
173178
private final long durableAckKeepaliveIntervalNanos;
@@ -211,51 +216,6 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
211216
private final long reconnectMaxDurationMillis;
212217
private final WebSocketResponse response = new WebSocketResponse();
213218
private final ResponseHandler responseHandler = new ResponseHandler();
214-
// Delta symbol dictionary catch-up state (see swapClient). Active in memory
215-
// mode, and in disk mode whenever the per-slot persisted dictionary opened --
216-
// fresh slots included, not just recovered / orphan-drained ones. On a
217-
// recovered / orphan-drained slot the constructor additionally SEEDS sentDict*
218-
// from that persisted dictionary; a fresh slot starts with an empty mirror and
219-
// grows it as frames are sent.
220-
// deltaDictEnabled gates all of it. The loop mirrors, in sentDict*, every
221-
// symbol it has ever sent -- the concatenated [len varint][utf8] bytes in
222-
// global-id order (sentDictBytes*) plus the count (sentDictCount) -- so that
223-
// on reconnect it can re-register the whole dictionary on the fresh server
224-
// (which discards its dictionary on every disconnect) before replaying frames
225-
// whose deltas start above id 0. All of this is touched only by the I/O thread.
226-
// Footprint note: this mirror is a SECOND copy of the dictionary -- the same
227-
// symbols the producer's GlobalSymbolDictionary already holds as Java Strings --
228-
// kept as native UTF-8 bytes for the reconnect-catch-up capability. So a
229-
// memory-mode connection's steady-state dictionary footprint is ~2x the symbol
230-
// set. It is bounded by distinct-symbol count (not per-row) and never trimmed
231-
// for the connection's lifetime (a reconnect may need the whole dictionary at
232-
// any moment), so it cannot be dropped; it is an intentional cost of the feature.
233-
private final boolean deltaDictEnabled;
234-
// Cap-gap attempts -- catch-ups that reached a node and found an entry too large
235-
// for its batch cap -- since the last SUCCESSFUL catch-up (see
236-
// MAX_CATCHUP_CAP_GAP_ATTEMPTS for the full budget accounting). A successful
237-
// catch-up resets it (sendDictCatchUp); a transient reconnect neither increments
238-
// nor resets it. NOT reset per connection -- it measures the cap-gap episode
239-
// across reconnects so a persistent gap eventually latches. I/O-thread-only.
240-
private int catchUpCapGapAttempts;
241-
// True once a real ring frame (data or commit) has been sent on the CURRENT
242-
// connection, as opposed to only the dictionary catch-up. The catch-up
243-
// consumes wire sequences (nextWireSeq), so nextWireSeq > 0 no longer implies
244-
// "the head frame was sent": onClose's poison-strike gate and
245-
// handleServerRejection's pre-send gate key off THIS instead. Without it, a
246-
// transient outage AFTER the catch-up but BEFORE the first data frame (a
247-
// flapping LB/middlebox that accepts the upgrade + catch-up then closes) would
248-
// be mistaken for a deterministic head-frame rejection and escalate to a
249-
// PROTOCOL_VIOLATION terminal -- breaking the store-and-forward "retry a
250-
// transient outage forever" contract. Reset per connection in
251-
// setWireBaselineWithCatchUp; set in trySendOne after a successful send.
252-
private boolean dataFrameSentThisConnection;
253-
private long sentDictBytesAddr;
254-
private int sentDictBytesCapacity;
255-
private int sentDictBytesLen;
256-
private int sentDictCount;
257-
// End position (native address) written by the last readVarintAt() call.
258-
private long varintEnd;
259219
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
260220
private final AtomicLong totalAcks = new AtomicLong();
261221
// Counters for observability of the durable-ack path. Both are zero
@@ -279,6 +239,49 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
279239
// by category. Includes both retriable and terminal outcomes — i.e. every
280240
// server-side rejection observed regardless of how the loop reacted.
281241
private final AtomicLong totalServerErrors = new AtomicLong();
242+
// Delta symbol dictionary catch-up state (see swapClient, deltaDictEnabled).
243+
// Active in memory mode, and in disk mode whenever the per-slot persisted
244+
// dictionary opened -- fresh slots included, not just recovered / orphan-drained
245+
// ones. On a recovered / orphan-drained slot the constructor additionally SEEDS
246+
// sentDict* from that persisted dictionary; a fresh slot starts with an empty
247+
// mirror and grows it as frames are sent. The loop mirrors, in sentDict*, every
248+
// symbol it has ever sent -- the concatenated [len varint][utf8] bytes in
249+
// global-id order (sentDictBytes*) plus the count (sentDictCount) -- so that on
250+
// reconnect it can re-register the whole dictionary on the fresh server (which
251+
// discards its dictionary on every disconnect) before replaying frames whose
252+
// deltas start above id 0. All of this is touched only by the I/O thread.
253+
// Footprint note: this mirror is a SECOND copy of the dictionary -- the same
254+
// symbols the producer's GlobalSymbolDictionary already holds as Java Strings --
255+
// kept as native UTF-8 bytes for the reconnect-catch-up capability. So a
256+
// memory-mode connection's steady-state dictionary footprint is ~2x the symbol
257+
// set. It is bounded by distinct-symbol count (not per-row) and never trimmed for
258+
// the connection's lifetime (a reconnect may need the whole dictionary at any
259+
// moment), so it cannot be dropped; it is an intentional cost of the feature.
260+
private long sentDictBytesAddr;
261+
private int sentDictBytesCapacity;
262+
private int sentDictBytesLen;
263+
private int sentDictCount;
264+
// Cap-gap attempts -- catch-ups that reached a node and found an entry too large
265+
// for its batch cap -- since the last SUCCESSFUL catch-up (see
266+
// MAX_CATCHUP_CAP_GAP_ATTEMPTS for the full budget accounting). A successful
267+
// catch-up resets it (sendDictCatchUp); a transient reconnect neither increments
268+
// nor resets it. NOT reset per connection -- it measures the cap-gap episode
269+
// across reconnects so a persistent gap eventually latches. I/O-thread-only.
270+
private int catchUpCapGapAttempts;
271+
// True once a real ring frame (data or commit) has been sent on the CURRENT
272+
// connection, as opposed to only the dictionary catch-up. The catch-up consumes
273+
// wire sequences (nextWireSeq), so nextWireSeq > 0 no longer implies "the head
274+
// frame was sent": onClose's poison-strike gate and handleServerRejection's
275+
// pre-send gate key off THIS instead. Without it, a transient outage AFTER the
276+
// catch-up but BEFORE the first data frame (a flapping LB/middlebox that accepts
277+
// the upgrade + catch-up then closes) would be mistaken for a deterministic
278+
// head-frame rejection and escalate to a PROTOCOL_VIOLATION terminal -- breaking
279+
// the store-and-forward "retry a transient outage forever" contract. Reset per
280+
// connection in setWireBaselineWithCatchUp; set in trySendOne after a successful
281+
// send.
282+
private boolean dataFrameSentThisConnection;
283+
// End position (native address) written by the last readVarintAt() call.
284+
private long varintEnd;
282285
private WebSocketClient client;
283286
// Optional: when non-null, every server-rejection error (retriable and
284287
// terminal alike) is offered to the dispatcher for async delivery to the user's

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,62 @@ public long maxSymbolDeltaEnd(int headerMagic, int flagsOffset, int flagDeltaMas
606606
return maxEnd;
607607
}
608608

609+
/**
610+
* Highest {@code deltaStart} any delta-flagged frame at or below
611+
* {@code maxFsnInclusive} carries, or {@code 0} when NO delta-flagged frame is
612+
* present. A result of {@code 0} means every surviving symbol-bearing frame is
613+
* SELF-SUFFICIENT -- it re-registers its dictionary from id 0 (a full-dict
614+
* frame), so the slot replays with no persisted dictionary at all; a result
615+
* {@code > 0} means at least one frame is a non-self-sufficient delta whose ids
616+
* depend on a prior frame's (or the persisted dictionary's) registrations.
617+
* {@link CursorSendEngine} pairs this with {@link #maxSymbolDeltaEnd} at
618+
* recovery: when the persisted {@code .symbol-dict} is a subset of the ids the
619+
* frames reference BUT this returns {@code 0}, the slot was written in full-dict
620+
* fallback (the dictionary never opened when writing) and recovers in full-dict
621+
* mode instead of failing clean; a torn DELTA dictionary has a {@code
622+
* deltaStart > 0} frame and still fails clean. Same read-only frame walk and
623+
* layout as {@link #maxSymbolDeltaEnd}; only frames at or below {@code
624+
* maxFsnInclusive} count (the retired orphan-deferred tail is excluded).
625+
*/
626+
public long maxSymbolDeltaStart(int headerMagic, int flagsOffset, int flagDeltaMask, int qwpHeaderSize, long maxFsnInclusive) {
627+
long maxStart = 0L;
628+
long off = HEADER_SIZE;
629+
long frames = frameCount;
630+
for (long i = 0; i < frames; i++) {
631+
long fsn = baseSeq + i;
632+
int payloadLen = Unsafe.getUnsafe().getInt(mmapAddress + off + 4);
633+
long payload = mmapAddress + off + FRAME_HEADER_SIZE;
634+
// Skip the orphan-deferred tail (fsn > maxFsnInclusive): retired, never
635+
// sent, so its baseline must not count. off still advances below.
636+
if (fsn <= maxFsnInclusive
637+
&& payloadLen >= qwpHeaderSize
638+
&& payloadLen > flagsOffset
639+
&& Unsafe.getUnsafe().getInt(payload) == headerMagic
640+
&& (Unsafe.getUnsafe().getByte(payload + flagsOffset) & flagDeltaMask) != 0) {
641+
long p = payload + qwpHeaderSize;
642+
long limit = payload + payloadLen;
643+
long deltaStart = 0L;
644+
int shift = 0;
645+
while (p < limit) {
646+
byte b = Unsafe.getUnsafe().getByte(p++);
647+
deltaStart |= (long) (b & 0x7F) << shift;
648+
if ((b & 0x80) == 0) {
649+
break;
650+
}
651+
shift += 7;
652+
if (shift > 35) {
653+
break; // corrupt run; recovered frames are CRC-valid, so defensive only
654+
}
655+
}
656+
if (deltaStart > maxStart) {
657+
maxStart = deltaStart;
658+
}
659+
}
660+
off += FRAME_HEADER_SIZE + payloadLen;
661+
}
662+
return maxStart;
663+
}
664+
609665
/**
610666
* Number of frames written since {@link #create} (or recovered by
611667
* {@link #openExisting}). Used by {@code SegmentRing} to compute

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,25 @@ public synchronized long maxSymbolDeltaEnd(int headerMagic, int flagsOffset, int
566566
return Math.max(maxEnd, end);
567567
}
568568

569+
/**
570+
* Highest {@code deltaStart} any symbol-dict delta frame at or below
571+
* {@code maxFsnInclusive} carries (0 when none, or when every such frame is
572+
* self-sufficient). See {@link MmapSegment#maxSymbolDeltaStart}; paired with
573+
* {@link #maxSymbolDeltaEnd} at recovery to tell a full-dict-fallback slot
574+
* (recoverable with no dictionary) from a torn delta dictionary (fails clean).
575+
*/
576+
public synchronized long maxSymbolDeltaStart(int headerMagic, int flagsOffset, int flagDeltaMask, int qwpHeaderSize, long maxFsnInclusive) {
577+
long maxStart = 0L;
578+
for (int i = 0, n = sealedSegments.size(); i < n; i++) {
579+
long start = sealedSegments.get(i).maxSymbolDeltaStart(headerMagic, flagsOffset, flagDeltaMask, qwpHeaderSize, maxFsnInclusive);
580+
if (start > maxStart) {
581+
maxStart = start;
582+
}
583+
}
584+
long start = active.maxSymbolDeltaStart(headerMagic, flagsOffset, flagDeltaMask, qwpHeaderSize, maxFsnInclusive);
585+
return Math.max(maxStart, start);
586+
}
587+
569588
public MmapSegment getActive() {
570589
return active;
571590
}

0 commit comments

Comments
 (0)