Skip to content

Commit 8fcd835

Browse files
glasstigerclaude
andcommitted
Fix delta symbol-dict recovery and reconnect bugs
Close three ways the delta symbol-dictionary feature could lose or corrupt data on the reconnect and store-and-forward recovery paths. Run the torn-dictionary guard unconditionally. trySendOne gated the guard on deltaDictEnabled, which CursorSendEngine reports false when a recovered disk slot cannot open its persisted dictionary (fd exhaustion, a read-only remount, ENOSPC). The recorded frames are still delta frames, so replaying them against a fresh empty-dictionary server null-padded the missing ids and silently corrupted the table. The guard now decodes the delta start for every frame and fails terminally on a gap regardless of the flag; only the sent-dictionary mirror stays gated. Stop treating a catch-up frame as the head data frame. sendCatchUpChunk advances nextWireSeq, but onClose's poison-strike gate and handleServerRejection's pre-send gate read nextWireSeq > 0 as "a data frame was sent". A transient non-orderly close or NACK after the catch-up but before the first replay frame then charged a poison strike on a frame that never left, and after a few flaps escalated a transient outage to a PROTOCOL_VIOLATION terminal that quarantines an orphan drainer. A new dataFrameSentThisConnection flag, set only after a real ring frame sends, now gates both decisions, so the drainer keeps retrying as Invariant B requires. Bound the commit message's dictionary delta to the sent watermark. sendCommitMessage skips the write-ahead persist yet encoded a delta up to currentBatchMaxSymbolId, so a symbol left in the batch by a cancelled row (cancelRow rolls back neither currentBatchMaxSymbolId nor the global registration) rode out on the commit frame without being persisted. A recovered slot then under-seeded the producer against the surviving frame and misattributed the reused id. The commit now caps the delta at sentMaxSymbolId in delta mode, giving an empty delta. Each fix carries a regression test proven to fail when the fix is reverted: a directory-shadowed .symbol-dict (guard), a close after only the catch-up (poison gate), and a cancelled-row symbol on a transactional commit (delta bound). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8fd9ad1 commit 8fcd835

4 files changed

Lines changed: 264 additions & 39 deletions

File tree

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3623,10 +3623,23 @@ private void sendCommitMessage() {
36233623
LOG.debug("Sending commit message for deferred batch");
36243624
}
36253625
encoder.setDeferCommit(false);
3626-
// A commit carries no rows and no new symbols; in delta mode its empty
3627-
// delta simply starts at the server's current dictionary size.
3626+
// A commit carries no rows, and it must also carry NO new symbols. Unlike
3627+
// the flush paths, sendCommitMessage does NOT write-ahead-persist the
3628+
// dictionary, so shipping a symbol here would put an id on the wire that a
3629+
// recovered slot cannot rebuild from the persisted .symbol-dict, diverging
3630+
// the producer dictionary from the surviving frames and silently
3631+
// misattributing reused ids after a crash. currentBatchMaxSymbolId can sit
3632+
// ABOVE sentMaxSymbolId (e.g. a cancelled row: cancelRow does not roll back
3633+
// currentBatchMaxSymbolId or unregister the symbol), so bound the delta at
3634+
// what has already been sent -- and therefore already persisted. In delta
3635+
// mode pass sentMaxSymbolId, yielding an empty delta
3636+
// [sentMaxSymbolId+1 .. sentMaxSymbolId]; in full-dict mode keep
3637+
// currentBatchMaxSymbolId so the frame stays self-sufficient. Any symbol a
3638+
// cancelled row leaked is picked up (and persisted) by the next real flush,
3639+
// whose persistNewSymbolsBeforePublish resumes from pd.size().
3640+
int commitBatchMaxId = deltaDictEnabled ? sentMaxSymbolId : currentBatchMaxSymbolId;
36283641
encoder.beginMessage(0, globalSymbolDictionary,
3629-
symbolDeltaBaseline(), currentBatchMaxSymbolId);
3642+
symbolDeltaBaseline(), commitBatchMaxId);
36303643
int messageSize = encoder.finishMessage();
36313644
QwpBufferWriter buffer = encoder.getBuffer();
36323645
ensureActiveBufferReady();

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

Lines changed: 64 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,18 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
203203
// for the connection's lifetime (a reconnect may need the whole dictionary at
204204
// any moment), so it cannot be dropped; it is an intentional cost of the feature.
205205
private final boolean deltaDictEnabled;
206+
// True once a real ring frame (data or commit) has been sent on the CURRENT
207+
// connection, as opposed to only the dictionary catch-up. The catch-up
208+
// consumes wire sequences (nextWireSeq), so nextWireSeq > 0 no longer implies
209+
// "the head frame was sent": onClose's poison-strike gate and
210+
// handleServerRejection's pre-send gate key off THIS instead. Without it, a
211+
// transient outage AFTER the catch-up but BEFORE the first data frame (a
212+
// flapping LB/middlebox that accepts the upgrade + catch-up then closes) would
213+
// be mistaken for a deterministic head-frame rejection and escalate to a
214+
// PROTOCOL_VIOLATION terminal -- breaking the store-and-forward "retry a
215+
// transient outage forever" contract. Reset per connection in
216+
// setWireBaselineWithCatchUp; set in trySendOne after a successful send.
217+
private boolean dataFrameSentThisConnection;
206218
private long sentDictBytesAddr;
207219
private int sentDictBytesCapacity;
208220
private int sentDictBytesLen;
@@ -1980,6 +1992,11 @@ private void swapClient(WebSocketClient newClient) {
19801992
* first real connection via swapClient.
19811993
*/
19821994
private void setWireBaselineWithCatchUp(long replayStart) {
1995+
// Fresh connection: no data frame has been sent on it yet. Reset before the
1996+
// catch-up (which sends only dictionary frames) so onClose /
1997+
// handleServerRejection can tell "only the catch-up went out" from "the
1998+
// head data frame went out".
1999+
dataFrameSentThisConnection = false;
19832000
if (client != null && deltaDictEnabled && sentDictCount > 0) {
19842001
this.nextWireSeq = 0L;
19852002
// The catch-up may span several frames when the dictionary exceeds the
@@ -2338,37 +2355,44 @@ private boolean trySendOne() {
23382355
return false; // payload not fully published yet
23392356
}
23402357
long frameAddr = base + sendOffset + MmapSegment.FRAME_HEADER_SIZE;
2341-
// -1 unless this is a delta frame; the guard decodes it once here and
2342-
// accumulateSentDict reuses it post-send, so the delta header is parsed
2343-
// once per frame rather than twice.
2344-
int deltaStart = -1;
2345-
if (deltaDictEnabled) {
2346-
// Torn-dictionary guard. In normal operation a delta frame's start id
2347-
// never exceeds the dictionary coverage established so far (replayed
2348-
// frames overlap the catch-up dict; fresh frames extend it
2349-
// contiguously). A gap here means the persisted dictionary was torn --
2350-
// almost always by a host/power crash, which leaves segment frames on
2351-
// disk but loses recently-written dictionary entries (SF, like the rest
2352-
// of the store, is process-crash durable but not host-crash durable).
2353-
// Sending the frame would corrupt the table (the server would null-pad
2354-
// the missing ids), so fail terminally instead; the unreplayable data
2355-
// must be resent.
2356-
deltaStart = frameDeltaStart(frameAddr, payloadLen);
2357-
if (deltaStart > sentDictCount) {
2358-
recordFatal(new LineSenderException(
2359-
"recovered store-and-forward symbol dictionary is incomplete (likely a host crash): "
2360-
+ "frame delta start " + deltaStart + " exceeds recovered dictionary size "
2361-
+ sentDictCount + "; cannot replay without corrupting data -- resend required"));
2362-
return false;
2363-
}
2358+
// Torn-dictionary guard. Decode the delta start unconditionally (-1 for a
2359+
// non-delta frame); the guard MUST run even when deltaDictEnabled is false.
2360+
// A disk slot recovered with its persisted dictionary unavailable
2361+
// (PersistedSymbolDict.open() returned null -- fd exhaustion, a read-only
2362+
// remount, ENOSPC) reports deltaDictEnabled=false, yet its recorded frames
2363+
// are still DELTA frames (deltaStart > 0). Replaying those against a fresh
2364+
// empty-dictionary server would null-pad the missing ids and SILENTLY
2365+
// corrupt the table -- precisely what this guard exists to prevent -- so it
2366+
// cannot be gated on the very flag that goes false in that failure mode. In
2367+
// normal operation a delta frame's start id never exceeds the dictionary
2368+
// coverage established so far (replayed frames overlap the catch-up dict;
2369+
// fresh frames extend it contiguously), so a gap here means the recovered
2370+
// dictionary is incomplete (a host/power crash that lost recently-written
2371+
// entries, SF being process-crash but not host-crash durable). Fail
2372+
// terminally; the unreplayable data must be resent. Full-dict / fallback
2373+
// frames carry deltaStart=0 with sentDictCount=0, so 0 > 0 never
2374+
// false-positives; only the sent-dictionary mirror below stays gated on
2375+
// deltaDictEnabled.
2376+
int deltaStart = frameDeltaStart(frameAddr, payloadLen);
2377+
if (deltaStart > sentDictCount) {
2378+
recordFatal(new LineSenderException(
2379+
"recovered store-and-forward symbol dictionary is incomplete (likely a host crash): "
2380+
+ "frame delta start " + deltaStart + " exceeds recovered dictionary size "
2381+
+ sentDictCount + "; cannot replay without corrupting data -- resend required"));
2382+
return false;
23642383
}
23652384
try {
23662385
client.sendBinary(frameAddr, payloadLen);
23672386
} catch (Throwable t) {
23682387
fail(t);
23692388
return false;
23702389
}
2371-
if (deltaStart >= 0) {
2390+
// A real ring frame (data or commit) has now gone out on this connection,
2391+
// as opposed to only the dictionary catch-up. onClose / handleServerRejection
2392+
// key their poison-strike vs pre-send decision off this, not off nextWireSeq
2393+
// (which the catch-up advances).
2394+
dataFrameSentThisConnection = true;
2395+
if (deltaDictEnabled && deltaStart >= 0) {
23722396
// Mirror the symbols this frame introduced so a later reconnect can
23732397
// rebuild the whole dictionary. Idempotent on replay: a frame whose
23742398
// delta we already hold advances nothing.
@@ -2619,7 +2643,7 @@ public void onClose(int code, String reason) {
26192643
|| code == WebSocketCloseCode.GOING_AWAY;
26202644
LineSenderException cause = new LineSenderException(
26212645
"WebSocket closed by server: code=" + code + " reason=" + reason);
2622-
if (!orderly && nextWireSeq > 0) {
2646+
if (!orderly && dataFrameSentThisConnection) {
26232647
if (recordHeadRejectionStrike(Math.max(engine.ackedFsn(), highestOkFsn) + 1L)) {
26242648
haltOnPoisonedFrame("ws-close[" + code + ' '
26252649
+ WebSocketCloseCode.describe(code) + "]: " + reason,
@@ -2726,17 +2750,21 @@ private void handleServerRejection(long wireSeq) {
27262750
// value is only used to attribute an FSN to the error report --
27272751
// a rejection never advances the watermark.
27282752
long highestSent = nextWireSeq - 1L;
2729-
if (highestSent < 0L) {
2730-
// Pre-send rejection: server emitted an error frame before
2731-
// we sent anything on this connection (typical after a
2732-
// fresh swapClient — auth failure, server-initiated halt,
2733-
// etc.). The server-named wireSeq does not correspond to
2734-
// any frame we sent, so clamping it to 0 and acknowledging
2735-
// fsnAtZero would silently advance ackedFsn past a real
2736-
// unsent batch (fsnAtZero == ackedFsn + 1 right after a
2737-
// swap). Skip the watermark advance entirely; still surface
2738-
// the error so the user's handler sees it and HALT errors
2739-
// remain producer-observable.
2753+
if (!dataFrameSentThisConnection) {
2754+
// Pre-send rejection: the server emitted an error frame before we
2755+
// sent any DATA frame on this connection (typical after a fresh
2756+
// swapClient -- auth failure, server-initiated halt, or a rejection
2757+
// of the dictionary catch-up itself). nextWireSeq may be > 0 here
2758+
// because the catch-up consumed wire sequences, so this keys off
2759+
// dataFrameSentThisConnection, not highestSent >= 0 -- otherwise a
2760+
// transient NACK of a catch-up frame would take the post-send
2761+
// poison-strike path and could escalate a transient outage to a
2762+
// terminal. The server-named wireSeq does not correspond to any
2763+
// data frame we sent, so clamping it to 0 and acknowledging
2764+
// fsnAtZero would silently advance ackedFsn past a real unsent
2765+
// batch. Skip the watermark advance entirely; still surface the
2766+
// error so the user's handler sees it and HALT errors remain
2767+
// producer-observable.
27402768
handlePreSendRejection(wireSeq, status, category, policy);
27412769
return;
27422770
}

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/DeltaDictRecoveryTest.java

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,139 @@ public void testTornDictionaryFailsCleanlyInsteadOfCorrupting() throws Exception
227227
});
228228
}
229229

230+
@Test
231+
public void testUnopenablePersistedDictStillGuardsAgainstReplayingDeltaFrames() throws Exception {
232+
// C1 regression: when a recovered disk slot's persisted dictionary cannot be
233+
// OPENED (fd exhaustion, a read-only remount, ENOSPC -- simulated here by a
234+
// .symbol-dict that is a DIRECTORY, so both openRW and openCleanRW fail),
235+
// CursorSendEngine.isDeltaDictEnabled() returns false. The recorded frames
236+
// are still DELTA frames, and replaying them against a fresh
237+
// empty-dictionary server would null-pad the missing ids and SILENTLY
238+
// corrupt the table. The torn-dictionary guard must fire regardless of
239+
// deltaDictEnabled -- pre-fix it was gated on that very flag, so the
240+
// corrupting frame sailed through unguarded. Unlike
241+
// testTornDictionaryFailsCleanlyInsteadOfCorrupting (dict present but empty,
242+
// deltaDictEnabled=true), here the dict is UNOPENABLE (deltaDictEnabled=false).
243+
assertMemoryLeak(() -> {
244+
// Phase 1: each row introduces a new symbol => frame i carries deltaStart=i.
245+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
246+
int port = silent.getPort();
247+
silent.start();
248+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
249+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir
250+
+ ";close_flush_timeout_millis=0;";
251+
try (Sender s1 = Sender.fromConfig(cfg)) {
252+
for (int i = 0; i < 6; i++) {
253+
s1.table("m").symbol("s", "sym-" + i).longColumn("v", i).atNow();
254+
s1.flush();
255+
}
256+
}
257+
}
258+
259+
// Make the persisted dictionary UNOPENABLE: replace the .symbol-dict file
260+
// with a directory of the same name so PersistedSymbolDict.open() returns
261+
// null (both openRW and openCleanRW fail) and the engine reports
262+
// deltaDictEnabled=false. Stamp the watermark at FSN 2 so replay starts
263+
// at FSN 3 -- a frame whose delta starts at id 3, with ids 0..2 living
264+
// only in the now-unreadable dictionary.
265+
java.nio.file.Path slot = Paths.get(sfDir, "default");
266+
java.nio.file.Path dict = slot.resolve(".symbol-dict");
267+
java.nio.file.Files.delete(dict);
268+
java.nio.file.Files.createDirectory(dict);
269+
writeAckWatermark(slot.resolve(".ack-watermark"), 2);
270+
271+
// Phase 2: recover against a fresh counting server. The guard must fire
272+
// (frame deltaStart 3 > recovered dictionary size 0) and fail terminally
273+
// rather than send a gapped frame that would corrupt the table.
274+
CountingHandler handler = new CountingHandler();
275+
try (TestWebSocketServer good = new TestWebSocketServer(handler)) {
276+
int port = good.getPort();
277+
good.start();
278+
Assert.assertTrue(good.awaitStart(5, TimeUnit.SECONDS));
279+
280+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";";
281+
LineSenderException terminal = null;
282+
Sender s2 = Sender.fromConfig(cfg);
283+
try {
284+
long deadline = System.currentTimeMillis() + 10_000;
285+
while (System.currentTimeMillis() < deadline && terminal == null) {
286+
try {
287+
s2.flush();
288+
Thread.sleep(20);
289+
} catch (LineSenderException e) {
290+
terminal = e;
291+
}
292+
}
293+
} finally {
294+
try {
295+
s2.close();
296+
} catch (LineSenderException e) {
297+
if (terminal == null) {
298+
terminal = e;
299+
}
300+
}
301+
}
302+
Assert.assertEquals("no delta frame may be replayed when the persisted dictionary is unopenable",
303+
0, handler.frames.get());
304+
Assert.assertNotNull("an unopenable dictionary must surface a terminal error", terminal);
305+
Assert.assertTrue(terminal.getMessage(),
306+
terminal.getMessage().contains("symbol dictionary is incomplete"));
307+
}
308+
});
309+
}
310+
311+
@Test
312+
public void testCommitMessageDoesNotShipUnpersistedLeakedSymbol() throws Exception {
313+
// C3 regression: sendCommitMessage does NOT write-ahead-persist the
314+
// dictionary, so its frame must carry NO new symbol. A symbol left in the
315+
// batch by a cancelled row -- cancelRow rolls back neither
316+
// currentBatchMaxSymbolId nor the global-dictionary registration -- must not
317+
// ride out on the commit frame: doing so puts an id on the wire that a
318+
// recovered slot cannot rebuild from .symbol-dict, diverging the producer
319+
// dictionary from the surviving frames and silently misattributing reused
320+
// ids after a crash. The commit's delta must be bounded by sentMaxSymbolId
321+
// (empty here), not currentBatchMaxSymbolId. Memory mode suffices to observe
322+
// the wire behaviour; close() drains every frame to the server first.
323+
assertMemoryLeak(() -> {
324+
DictReconstructingHandler handler = new DictReconstructingHandler();
325+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
326+
int port = server.getPort();
327+
server.start();
328+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
329+
330+
// Transactional + autoFlushRows=1: every completed row auto-flushes
331+
// as a DEFERRED batch (setting hasDeferredMessages); an explicit
332+
// flush() then emits the commit message.
333+
Sender sender = Sender.builder("ws::addr=localhost:" + port + ";")
334+
.transactional(true)
335+
.autoFlushRows(1)
336+
.build();
337+
try {
338+
// Row 1 registers "a"@0 and auto-flushes it deferred.
339+
sender.table("m").symbol("s", "a").longColumn("v", 1L).atNow();
340+
// Register "b"@1 on a row that is then cancelled: "b" stays in
341+
// the global dictionary and currentBatchMaxSymbolId advances to
342+
// 1, but nothing persists or sends it.
343+
sender.table("m").symbol("s", "b");
344+
sender.cancelRow();
345+
// Commit the deferred batch. The commit frame must carry an
346+
// EMPTY delta -- NOT "b"@1.
347+
sender.flush();
348+
} finally {
349+
sender.close(); // drains every frame (incl. the commit) to the server
350+
}
351+
352+
// The server's reconstructed dictionary must hold ONLY "a". Pre-fix
353+
// the commit shipped "b"@1, so the server saw a second symbol.
354+
List<String> dict = handler.dictSnapshot();
355+
Assert.assertEquals("commit frame must not ship the cancelled row's leaked symbol "
356+
+ "(recovery would then diverge from the persisted dictionary): " + dict,
357+
1, dict.size());
358+
Assert.assertEquals("a", dict.get(0));
359+
}
360+
});
361+
}
362+
230363
@Test
231364
public void testFailedPublishDoesNotDuplicatePersistedSymbols() throws Exception {
232365
// Regression: persistNewSymbolsBeforePublish is a write-ahead -- it runs

0 commit comments

Comments
 (0)