Skip to content

Commit c181e4f

Browse files
glasstigerclaude
andcommitted
Recover slots whose symbol dictionary is damaged
The QWP store-and-forward recovery path treated a damaged .symbol-dict as unrecoverable in two places, stranding slots that are in fact replayable. Both stem from keying on the dictionary FILE rather than on what the surviving frames themselves define. CursorWebSocketSendLoop ran the torn-dictionary guard unconditionally but gated accumulateSentDict, the reconnect catch-up and the mirror seed on engine.isDeltaDictEnabled(). That flag goes false exactly when a disk slot's dictionary fails to open -- fd exhaustion, a read-only remount, ENOSPC -- which is precisely when the frames already on disk are still deltas. With the mirror gated off, sentDictCount froze at 0 while the ungated guard kept comparing against it, so the frame at deltaStart=1 latched a terminal and the background drainer quarantined the slot behind a .failed sentinel. Yet replaying that sequence from id 0 rebuilds the dictionary on the server contiguously and drains perfectly. The loop now always mirrors, always catches up and always guards: the mirror is its model of what the SERVER holds, so it must track every mode. isDeltaDictEnabled() now governs only what the producer emits and persists. seedGlobalDictionaryFromPersisted threw whenever the surviving frames out-reached the persisted dictionary. That bricked Sender.build() for slots the drainer replays fine, and permanently: build()'s catch releases the slot lock, but the sender that would have hosted the orphan drainer is the one that just failed, so the retry recovers the same slot and throws again. Relaxing the guard would have been worse than the bug -- with an empty dictionary the producer hands its next symbol id 0, an id the frames already define, and accumulateSentDict skips it, so the next catch-up replays the stale symbol and misattributes it silently. The producer now seeds from the dictionary's intact prefix AND THEN from the surviving frames' own delta sections, through collectReplaySymbolsAbove (MmapSegment -> SegmentRing -> CursorSendEngine). Those are the same two sources, in the same order, that the send loop's mirror is built from, so the producer's delta baseline and the mirror's coverage land on the same number by construction. A genuine gap -- ids introduced by frames since acked and trimmed away -- still fails clean, and now on exactly the condition the send loop's replay guard trips on, so producer and drainer agree on which slots are recoverable. The two cap-gap settle-budget tests derived their retriable loop bound from MAX_CATCHUP_CAP_GAP_ATTEMPTS itself, so a regression of that constant to 1 -- the very bug they name -- ran the loop zero times and let the test pass green. They now pin the budget against a literal. Tests: an untrimmed unopenable-dictionary slot must replay gap-free; a torn (subset) and a wholly lost dictionary must rebuild from the surviving frames and place a new symbol above the recovered tip. The two watermark-stamped siblings still fail clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2fa5cd9 commit c181e4f

7 files changed

Lines changed: 573 additions & 210 deletions

File tree

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

Lines changed: 74 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -3847,91 +3847,88 @@ private void resetSymbolDictStateForNewConnection() {
38473847
}
38483848

38493849
/**
3850-
* On recovery, repopulates the producer's {@link GlobalSymbolDictionary} from
3851-
* the slot's persisted dictionary (ids assigned in the same ascending order,
3852-
* so they match the recovered frames) and resumes the delta baseline at the
3853-
* recovered tip, so newly ingested symbols continue above the recovered ids.
3850+
* On recovery, repopulates the producer's {@link GlobalSymbolDictionary} so that newly
3851+
* ingested symbols continue ABOVE every id the surviving frames already define, and
3852+
* resumes the delta baseline at that tip.
38543853
* <p>
3855-
* Uses {@link GlobalSymbolDictionary#addRecoveredSymbol} (append, NOT de-dup):
3856-
* the persisted dictionary, the on-wire delta and the send-loop catch-up mirror
3857-
* all key on the entry POSITION (id), so the producer id space must match the
3858-
* persisted entry count exactly. {@code getOrAddSymbol} would collapse two
3859-
* source strings that decode to the same characters -- only malformed lone
3860-
* UTF-16 surrogates, which UTF-8-encode to {@code '?'} -- leaving this
3861-
* dictionary shorter than {@code pd.size()} and desyncing
3862-
* {@code sentMaxSymbolId} from the mirror's {@code sentDictCount = pd.size()},
3863-
* which silently misattributes later symbols after a reconnect.
3854+
* Seeds from TWO sources, in this order:
3855+
* <ol>
3856+
* <li>the slot's persisted {@code .symbol-dict} -- its intact prefix; then</li>
3857+
* <li>the surviving frames' OWN delta sections, for every id above that prefix
3858+
* ({@link CursorSendEngine#collectReplaySymbolsAbove}).</li>
3859+
* </ol>
3860+
* Those are exactly the two sources, in exactly the order, that the send loop's mirror
3861+
* is built from: its constructor seeds {@code sentDictCount} from the same dictionary,
3862+
* and {@code accumulateSentDict} then extends it from the same frames as they replay. So
3863+
* the producer's {@code sentMaxSymbolId + 1} and the loop's {@code sentDictCount} land on
3864+
* the same number BY CONSTRUCTION -- the invariant the torn-dictionary guard rests on --
3865+
* rather than by the two happening to agree.
38643866
* <p>
3865-
* <b>Host-crash tear guard.</b> The persisted dictionary is NOT fsync'd (see
3866-
* {@code PersistedSymbolDict}), so a host/power crash can lose its
3867-
* most-recently-written (highest-id) entries while the segment frames that
3868-
* introduced those ids survive -- and those newest frames, being the least
3869-
* likely to be acked, replay on recovery. The send loop's catch-up mirror then
3870-
* rebuilds the missing ids from those frames' own delta bytes, but THIS producer
3871-
* -- seeded only from the shorter dictionary -- would assign its next new symbol
3872-
* an id the surviving frames already define, putting two symbols on one id and
3873-
* silently misattributing values. The send loop's replay guard only catches a
3874-
* GAP ({@code deltaStart > sentDictCount}); a frame that introduces exactly the
3875-
* torn-off id ({@code deltaStart == pd.size()}) slips through and self-heals the
3876-
* mirror, leaving only this producer diverged. Detect it here -- the surviving
3877-
* frames reference an id at or beyond the recovered dictionary size -- and fail
3878-
* clean: the affected data must be resent, matching the design's torn-dict
3879-
* "resend required" contract.
3867+
* Uses {@link GlobalSymbolDictionary#addRecoveredSymbol} (append, NOT de-dup): the
3868+
* persisted dictionary, the on-wire delta and the mirror all key on the entry POSITION
3869+
* (id), so the producer's id space must match the recovered entry count exactly.
3870+
* {@code getOrAddSymbol} would collapse two source strings that decode to the same
3871+
* characters -- only malformed lone UTF-16 surrogates, which UTF-8-encode to {@code '?'}
3872+
* -- leaving this dictionary SHORTER than the count and silently misattributing later
3873+
* symbols.
38803874
* <p>
3881-
* The background drainer self-heals the mirror ONLY when a surviving frame
3882-
* STRADDLES the tear ({@code deltaStart <= pd.size() < deltaStart + deltaCount}):
3883-
* such a frame carries the torn-off ids in its own delta and
3884-
* {@code accumulateSentDict} re-registers them, so the drainer drains the slot.
3885-
* But when the symbol-introducing frames were already acked and trimmed and only
3886-
* a HIGHER-baseline frame survives ({@code deltaStart > pd.size()} -- e.g. a
3887-
* commit or a symbol-reusing frame, since {@code beginMessage} always sets the
3888-
* delta flag), the drainer's own replay guard ({@code deltaStart > sentDictCount})
3889-
* fires too and quarantines the slot: the recorded bytes are not silently lost,
3890-
* but the slot is NOT auto-drained -- it must be resent. That is a deliberate
3891-
* CONSERVATIVE over-strand -- the guard keys on {@code deltaStart}, not on the
3892-
* frame's actual highest referenced id, to avoid parsing row data at recovery,
3893-
* so it may reject a frame whose rows reference only ids the truncated
3894-
* dictionary still holds. It fails clean rather than risk a silent id shift.
3895-
* Only a host crash reaches this -- a process crash keeps the page cache, so the
3896-
* write-ahead ordering keeps the dictionary a superset of the frames.
3875+
* <b>Why seeding from the frames matters.</b> The dictionary is not fsync'd (see
3876+
* {@code PersistedSymbolDict}), so a host/power crash can tear off its newest entries
3877+
* while the segment frames that introduced those ids survive -- and those newest frames,
3878+
* being the least likely to be acked, are exactly the ones that replay. Seeded from the
3879+
* short dictionary alone, this producer would hand its next new symbol an id those frames
3880+
* already define, putting two symbols on one id and silently misattributing values. The
3881+
* old code detected that and threw, which was safe but far too blunt: it bricked
3882+
* {@code build()} for slots the background drainer replays PERFECTLY, because the frames
3883+
* carry the torn-off symbols in their own deltas and {@code accumulateSentDict} rebuilds
3884+
* the dictionary from them. This method now rebuilds the producer from the same bytes,
3885+
* so a torn -- or entirely lost -- dictionary is recoverable whenever the surviving
3886+
* frames define the ids themselves. The next flush's write-ahead persist then re-writes
3887+
* those ids (it resumes from {@code pd.size()}), healing the side-file on disk.
3888+
* <p>
3889+
* <b>What still fails clean.</b> A genuine GAP: the ids below a surviving frame's delta
3890+
* start were introduced by frames that were acked and TRIMMED away, so they lived only in
3891+
* the lost dictionary and nothing can rebuild them.
3892+
* {@code collectReplaySymbolsAbove} returns -1 for that and we throw. It is the same
3893+
* condition the send loop's replay guard ({@code deltaStart > sentDictCount}) trips on, so
3894+
* producer and drainer now agree on exactly which slots are recoverable, instead of the
3895+
* producer rejecting slots the drainer drains.
38973896
*/
38983897
private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {
3899-
if (pd == null) {
3898+
if (cursorEngine == null) {
39003899
return;
39013900
}
3902-
// Run the torn-dictionary guard BEFORE the empty-dictionary short-circuit
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.
3920-
if (cursorEngine != null && cursorEngine.recoveredMaxSymbolId() >= pd.size()) {
3901+
// 1. The dictionary's intact prefix. addRecoveredSymbol appends without de-dup, so
3902+
// the producer's size tracks pd.size() exactly -- which is what the send loop's
3903+
// mirror also seeds sentDictCount from.
3904+
int baseline = 0;
3905+
if (pd != null && pd.size() > 0) {
3906+
ObjList<String> persisted = pd.readLoadedSymbols();
3907+
for (int i = 0, n = persisted.size(); i < n; i++) {
3908+
globalSymbolDictionary.addRecoveredSymbol(persisted.getQuick(i));
3909+
}
3910+
baseline = globalSymbolDictionary.size();
3911+
}
3912+
// 2. Everything the surviving frames define above that prefix, straight out of their
3913+
// own delta sections -- the same bytes, in the same order, accumulateSentDict will
3914+
// feed the mirror as those frames go back on the wire.
3915+
ObjList<String> fromFrames = new ObjList<>();
3916+
long coverage = cursorEngine.collectReplaySymbolsAbove(baseline, fromFrames);
3917+
if (coverage < 0) {
39213918
throw new LineSenderException(
3922-
"recovered store-and-forward symbol dictionary is a subset of the surviving frames "
3923-
+ "(likely a host crash tore its unsynced tail): frames reference symbol id "
3924-
+ cursorEngine.recoveredMaxSymbolId() + " but the recovered dictionary holds only "
3925-
+ pd.size() + " id(s); resuming would reuse ids the frames already define -- "
3926-
+ "resend the affected data");
3927-
}
3928-
if (pd.size() == 0) {
3929-
return;
3930-
}
3931-
ObjList<String> symbols = pd.readLoadedSymbols();
3932-
for (int i = 0, n = symbols.size(); i < n; i++) {
3933-
globalSymbolDictionary.addRecoveredSymbol(symbols.getQuick(i));
3934-
}
3919+
"recovered store-and-forward symbol dictionary is incomplete and cannot be "
3920+
+ "rebuilt from the surviving frames (likely a host crash tore its unsynced "
3921+
+ "tail): the frames reference symbol ids below their own delta start, which "
3922+
+ "were introduced by frames since acked and trimmed away, so nothing still "
3923+
+ "holds them; the recovered dictionary holds only "
3924+
+ (pd == null ? 0 : pd.size()) + " id(s) -- resend the affected data");
3925+
}
3926+
for (int i = 0, n = fromFrames.size(); i < n; i++) {
3927+
globalSymbolDictionary.addRecoveredSymbol(fromFrames.getQuick(i));
3928+
}
3929+
// Producer baseline == the coverage the replay will establish == the mirror's
3930+
// sentDictCount once those frames have gone out. The first new frame therefore
3931+
// starts its delta exactly at the tip, and the replay guard passes.
39353932
sentMaxSymbolId = globalSymbolDictionary.size() - 1;
39363933
}
39373934

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,44 @@ public synchronized void close() {
661661
}
662662
}
663663

664+
/**
665+
* Rebuilds, from the surviving frames' OWN delta sections, the symbols the upcoming
666+
* replay will register ABOVE {@code baseline}, appending them to {@code out} in
667+
* ascending id order. Returns the coverage the replay establishes (one past the highest
668+
* id it registers), or {@code -1} when the frames have a genuine GAP above the baseline.
669+
* <p>
670+
* The producer seeds its dictionary from the persisted {@code .symbol-dict} and THEN
671+
* from this, so it is fed the same entries, in the same order, that the send loop's
672+
* {@code accumulateSentDict} will feed its mirror from as those very frames replay. The
673+
* producer's delta baseline and the loop's mirror coverage therefore land on the same
674+
* number by construction, which is the invariant the torn-dictionary guard rests on.
675+
* <p>
676+
* This is what makes a slot whose dictionary was torn -- or lost outright -- still
677+
* recoverable when the surviving frames define the ids themselves (they carry the
678+
* symbols in their own deltas; it is why the orphan drainer can drain such a slot). Only
679+
* a real gap, where the ids were introduced by frames since acked and trimmed away, is
680+
* unrecoverable -- and that is precisely when this returns -1.
681+
* <p>
682+
* Bounded above by {@link #recoveredCommitBoundaryFsn} like {@link #recoveredMaxSymbolId}:
683+
* frames past it are the aborted orphan-deferred tail, retired without ever being
684+
* transmitted, so their ids never reach a server and must not inflate the baseline.
685+
*/
686+
public long collectReplaySymbolsAbove(int baseline, ObjList<String> out) {
687+
if (ring == null) {
688+
return baseline;
689+
}
690+
return ring.collectReplaySymbolsAbove(
691+
QwpConstants.MAGIC_MESSAGE,
692+
QwpConstants.HEADER_OFFSET_FLAGS,
693+
QwpConstants.FLAG_DELTA_SYMBOL_DICT,
694+
QwpConstants.HEADER_SIZE,
695+
ackedFsn() + 1L,
696+
recoveredCommitBoundaryFsn,
697+
baseline,
698+
out
699+
);
700+
}
701+
664702
/**
665703
* Pass-through to {@link SegmentRing#findSegmentContaining(long)}.
666704
*/

0 commit comments

Comments
 (0)