Skip to content

Commit 6d14727

Browse files
glasstigerclaude
andcommitted
Harden delta symbol-dict send and persist paths
Four follow-ups from the delta symbol-dictionary review. Catch-up NACK no longer launders the poison detector. After a reconnect the loop ships the head data frame before tryReceiveAcks reads the server's NACK of a dictionary catch-up frame, so handleServerRejection saw dataFrameSentThisConnection=true and attributed the NACK a wire seq below the replay head -- fsn = fsnAtZero+cappedSeq, negative when replayStart < catchUpFrames. recordHeadRejectionStrike then set poisonFsn to that value (often -1, the "no suspect" sentinel), wiping a genuine in-progress poison run and reporting a bogus fsn. Route an fsn <= ackedFsn rejection to the pre-send path (surface + recycle, no strike, no watermark advance), symmetric with the success path's engine.acknowledge() no-op below ackedFsn. A real replayed data frame is at fsn > ackedFsn, so it is never caught. Regression test: testPostSendCatchUpNackDoesNotStrikeOrLaunderPoisonState. Persist short-write now surfaces as LineSenderException. A disk-full during the write-ahead persist threw a raw IllegalStateException that escaped flush() unwrapped, unlike every other flush-path failure (e.g. the cursor append in sealAndSwapBuffer). Wrap it so a caller catching LineSenderException around flush() sees a persist disk-full too; a JVM Error still propagates. PersistedSymbolDict close() and the append methods are synchronized. close() is callable from any thread (a shutdown hook); without mutual exclusion a close racing an in-flight append could free the scratch buffer or close the fd mid-write and let the write land on a descriptor the OS has reused for another file (silent cross-file corruption). The appendSymbols re-encode branch now has a regression test. After a failed publish leaves the durable dictionary size ahead of the wire baseline, a later flush introducing a new symbol re-encodes only the [pd.size() .. currentBatchMaxSymbolId] suffix; keying that off pd.size() (not sentMaxSymbolId+1) keeps it idempotent. Regression test: testFailedPublishThenNewSymbolPersistsSuffixWithoutDuplicating. Both new tests fail with their production hunk reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7a95653 commit 6d14727

5 files changed

Lines changed: 181 additions & 15 deletions

File tree

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3707,14 +3707,30 @@ private void persistNewSymbolsBeforePublish() {
37073707
// failed publish the durable size has run ahead of the wire baseline, so
37083708
// the frame's delta covers MORE than remains to persist; then re-encode
37093709
// just the [from .. currentBatchMaxSymbolId] suffix.
3710-
if (from == sentMaxSymbolId + 1) {
3711-
QwpBufferWriter buffer = encoder.getBuffer();
3712-
pd.appendRawEntries(
3713-
buffer.getBufferPtr() + encoder.getDeltaEntriesStart(),
3714-
encoder.getDeltaEntriesLen(),
3715-
currentBatchMaxSymbolId - from + 1);
3716-
} else {
3717-
pd.appendSymbols(globalSymbolDictionary, from, currentBatchMaxSymbolId);
3710+
try {
3711+
if (from == sentMaxSymbolId + 1) {
3712+
QwpBufferWriter buffer = encoder.getBuffer();
3713+
pd.appendRawEntries(
3714+
buffer.getBufferPtr() + encoder.getDeltaEntriesStart(),
3715+
encoder.getDeltaEntriesLen(),
3716+
currentBatchMaxSymbolId - from + 1);
3717+
} else {
3718+
pd.appendSymbols(globalSymbolDictionary, from, currentBatchMaxSymbolId);
3719+
}
3720+
} catch (Throwable t) {
3721+
// A short write (disk full / quota) to the persisted dictionary throws
3722+
// a low-level IllegalStateException. Surface it as a LineSenderException
3723+
// -- like every other flush-path failure, e.g. the cursor append in
3724+
// sealAndSwapBuffer -- so a caller catching LineSenderException around
3725+
// flush() also catches a disk-full during the write-ahead persist. The
3726+
// persist ran before publish and pd.size() did not advance on the short
3727+
// write, so the still-buffered rows re-persist the same range
3728+
// idempotently on retry. A JVM Error is never a persist failure; let it
3729+
// propagate.
3730+
if (t instanceof Error) {
3731+
throw (Error) t;
3732+
}
3733+
throw new LineSenderException("failed to persist symbol dictionary before publish", t);
37183734
}
37193735
}
37203736

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2811,6 +2811,25 @@ private void handleServerRejection(long wireSeq) {
28112811
wireSeq, highestSent);
28122812
}
28132813
long fsn = fsnAtZero + cappedSeq;
2814+
if (fsn <= engine.ackedFsn()) {
2815+
// The clamped wire seq maps at or below the replay head, so this
2816+
// NACK is for a dictionary catch-up frame -- which occupies the
2817+
// already-acked wire sequences below replayStart -- not a data frame
2818+
// this connection sent. (dataFrameSentThisConnection can be true here
2819+
// because trySendOne ships the head data frame before tryReceiveAcks
2820+
// reads the catch-up's NACK in the same loop iteration.) Attributing
2821+
// it a data FSN would key recordHeadRejectionStrike() off a
2822+
// below-baseline FSN -- negative when replayStart < catchUpFrames --
2823+
// colliding with the poisonFsn == -1 "no suspect" sentinel and
2824+
// laundering a genuine poison run, and would report a bogus FSN.
2825+
// Treat it exactly like a pre-send rejection: surface + recycle, no
2826+
// poison strike, no watermark advance. Symmetric with the success
2827+
// path, where engine.acknowledge() no-ops at or below ackedFsn. A
2828+
// real replayed data frame is at fsn > ackedFsn, so it is never
2829+
// caught here.
2830+
handlePreSendRejection(wireSeq, status, category, policy);
2831+
return;
2832+
}
28142833
// Best-effort table attribution: the parser populates
28152834
// response.tableNames on error frames the same way it does on
28162835
// STATUS_OK. If exactly one table was named, surface it; if

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

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,14 @@
8383
* A torn trailing entry from a crash mid-append is self-healing: {@link #open}
8484
* stops parsing at the first incomplete entry and the next append overwrites it.
8585
* <p>
86-
* <b>Lifecycle:</b> single-writer (the producer / user thread). Read once at
87-
* {@link #open} to seed in-memory state on recovery or orphan-drain. Owner
88-
* (the engine) closes it. Not thread-safe for concurrent writers.
86+
* <b>Lifecycle:</b> single-writer (the producer / user thread) for appends. Read
87+
* once at {@link #open} to seed in-memory state on recovery or orphan-drain. The
88+
* owner (the engine) closes it, and {@code close()} is callable from any thread
89+
* (a shutdown hook, test cleanup). {@code close()} and the append methods are
90+
* therefore {@code synchronized}: without that, a close racing an in-flight append
91+
* could free the scratch buffer or close the fd mid-write and let the write land
92+
* on a descriptor the OS has reused for another file (silent cross-file
93+
* corruption). Not thread-safe for concurrent writers.
8994
*/
9095
public final class PersistedSymbolDict implements QuietCloseable {
9196

@@ -188,7 +193,7 @@ public static void removeOrphan(String slotDir) {
188193
* appendOffset}, so a retry keyed off {@link #size()} re-persists the same range
189194
* at the same offset. No-op when the range is empty or the dictionary is closed.
190195
*/
191-
public void appendRawEntries(long addr, int len, int count) {
196+
public synchronized void appendRawEntries(long addr, int len, int count) {
192197
if (closed || count <= 0 || len <= 0) {
193198
return;
194199
}
@@ -208,7 +213,7 @@ public void appendRawEntries(long addr, int len, int count) {
208213
* (see the class-level durability note). Assigns the next dense id implicitly
209214
* (the entry's position).
210215
*/
211-
public void appendSymbol(CharSequence symbol) {
216+
public synchronized void appendSymbol(CharSequence symbol) {
212217
if (closed) {
213218
return;
214219
}
@@ -244,7 +249,7 @@ public void appendSymbol(CharSequence symbol) {
244249
* range and overwrites at the same offset. No-op when the range is empty or
245250
* the dictionary is closed.
246251
*/
247-
public void appendSymbols(GlobalSymbolDictionary dict, int from, int to) {
252+
public synchronized void appendSymbols(GlobalSymbolDictionary dict, int from, int to) {
248253
if (closed || to < from) {
249254
return;
250255
}
@@ -270,7 +275,7 @@ public void appendSymbols(GlobalSymbolDictionary dict, int from, int to) {
270275
}
271276

272277
@Override
273-
public void close() {
278+
public synchronized void close() {
274279
if (closed) {
275280
return;
276281
}

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,70 @@ public void testFailedPublishDoesNotDuplicatePersistedSymbols() throws Exception
480480
});
481481
}
482482

483+
@Test
484+
public void testFailedPublishThenNewSymbolPersistsSuffixWithoutDuplicating() throws Exception {
485+
// Regression for the re-encode (else) branch of persistNewSymbolsBeforePublish.
486+
// After a failed publish leaves the durable dictionary size ahead of the wire
487+
// baseline (pd.size() > sentMaxSymbolId+1), a later flush that introduces a NEW
488+
// symbol cannot reuse the frame's already-encoded fast-path bytes -- it
489+
// re-encodes just the [pd.size() .. currentBatchMaxSymbolId] suffix via
490+
// appendSymbols. Keying that off pd.size() (not sentMaxSymbolId+1) keeps it
491+
// idempotent: the already-persisted prefix is NOT re-appended.
492+
assertMemoryLeak(() -> {
493+
try (TestWebSocketServer silent = new TestWebSocketServer(new SilentHandler())) {
494+
int port = silent.getPort();
495+
silent.start();
496+
Assert.assertTrue(silent.awaitStart(5, TimeUnit.SECONDS));
497+
// Small segment: any frame carrying the padded row fails to publish with
498+
// PAYLOAD_TOO_LARGE deterministically (no backpressure timing).
499+
String cfg = "ws::addr=localhost:" + port + ";sf_dir=" + sfDir + ";sf_max_bytes=1024;";
500+
String pad = TestUtils.repeat("x", 2000); // frame >> 1024-byte segment
501+
Sender sender = Sender.fromConfig(cfg);
502+
try {
503+
// Flush 1: s0 is persisted (write-ahead) before the oversized frame
504+
// fails to publish. pd.size()=1, sentMaxSymbolId stays -1.
505+
sender.table("m").symbol("s", "s0").stringColumn("p", pad).longColumn("v", 1L).atNow();
506+
try {
507+
sender.flush();
508+
Assert.fail("oversized frame must fail to publish");
509+
} catch (LineSenderException expected) {
510+
}
511+
512+
// Add a NEW symbol s1 (id 1). The failed s0 row is still buffered, so
513+
// the batch is {s0, s1} and the durable size (1) has run ahead of the
514+
// wire baseline (-1) -- the state that selects the appendSymbols branch.
515+
sender.table("m").symbol("s", "s1").stringColumn("p", pad).longColumn("v", 2L).atNow();
516+
try {
517+
sender.flush();
518+
Assert.fail("oversized frame must fail to publish");
519+
} catch (LineSenderException expected) {
520+
}
521+
522+
// The else branch persisted ONLY s1 (the suffix). The dictionary holds
523+
// s0, s1 exactly once each. Pre-fix (appendSymbols from
524+
// sentMaxSymbolId+1) re-appended s0, giving size 3.
525+
PersistedSymbolDict pd = PersistedSymbolDict.open(Paths.get(sfDir, "default").toString());
526+
Assert.assertNotNull(pd);
527+
try {
528+
Assert.assertEquals("re-encode suffix must not duplicate the persisted prefix",
529+
2, pd.size());
530+
Assert.assertEquals("s0", pd.readLoadedSymbols().getQuick(0));
531+
Assert.assertEquals("s1", pd.readLoadedSymbols().getQuick(1));
532+
} finally {
533+
pd.close();
534+
}
535+
} finally {
536+
try {
537+
sender.close();
538+
} catch (LineSenderException ignored) {
539+
// close() re-flushes the still-buffered oversized rows and fails
540+
// again (PAYLOAD_TOO_LARGE); expected, resources still released.
541+
}
542+
}
543+
}
544+
});
545+
}
546+
483547
@Test
484548
public void testRecoveryAfterFailedPublishReplaysGapFree() throws Exception {
485549
// M3 end-to-end: chains a failed publish -> fresh-process recovery -> replay.

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,46 @@ public void testPoisonDwellHoldsEscalationUntilWallClockWindowElapses() throws E
656656
});
657657
}
658658

659+
@Test
660+
public void testPostSendCatchUpNackDoesNotStrikeOrLaunderPoisonState() throws Exception {
661+
// Regression: after a reconnect the loop ships the head DATA frame
662+
// (dataFrameSentThisConnection=true) BEFORE tryReceiveAcks reads the server's
663+
// NACK of a dictionary CATCH-UP frame in the same loop iteration. That NACK
664+
// names a wire seq below the replay head, so its fsn = fsnAtZero+cappedSeq is
665+
// at or below ackedFsn -- negative when replayStart < catchUpFrames. It must
666+
// NOT charge a poison strike: recordHeadRejectionStrike(fsn) would set
667+
// poisonFsn to that value (the common shape yields -1, the "no suspect"
668+
// sentinel), laundering any genuine in-progress poison run and reporting a
669+
// bogus fsn. The fix routes an fsn <= ackedFsn rejection to the pre-send path
670+
// (surface + recycle, no strike), symmetric with the success path's
671+
// engine.acknowledge() no-op below ackedFsn.
672+
TestUtils.assertMemoryLeak(() -> {
673+
List<WebSocketClient> clients = new ArrayList<>();
674+
try (CursorSendEngine engine = newEngine()) {
675+
appendFrames(engine, 2);
676+
CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
677+
// Model: 2 catch-up frames (wire seq 0,1) + 1 data frame (wire seq 2)
678+
// sent, nothing acked (ackedFsn = -1), so fsnAtZero = replayStart(0) -
679+
// catchUpFrames(2) = -2. A NACK of catch-up wire seq 0 maps to fsn -2,
680+
// strictly below the -1 sentinel so the assertion discriminates.
681+
setPostCatchUpDataFrameBaseline(loop, -2L, 3L);
682+
assertEquals("precondition: no poison suspect yet",
683+
-1L, getLongField(loop, "poisonFsn"));
684+
685+
deliverRetriableNack(loop, 0, "disk full");
686+
687+
// The catch-up NACK was surfaced + recycled but charged NO strike, so
688+
// the sentinel is intact. Pre-fix it was set to -2, laundering the
689+
// poison detector's state.
690+
assertEquals("catch-up NACK must not set/launder the poison sentinel",
691+
-1L, getLongField(loop, "poisonFsn"));
692+
loop.checkError();
693+
} finally {
694+
closeAll(clients);
695+
}
696+
});
697+
}
698+
659699
@Test
660700
public void testPostSendNotWritableNackNeverEscalatesToPoisonTerminal() throws Exception {
661701
// RETRIABLE_OTHER (NOT_WRITABLE) is a node-state verdict, not a frame
@@ -1058,6 +1098,28 @@ private static void setCatchUpWireSeqOnly(CursorWebSocketSendLoop loop, long cou
10581098
f.setLong(loop, count);
10591099
}
10601100

1101+
// Models a fresh connection that has shipped the dictionary catch-up (advancing
1102+
// fsnAtZero below the replay head) AND the head data frame: sets fsnAtZero,
1103+
// nextWireSeq, and dataFrameSentThisConnection=true together.
1104+
private static void setPostCatchUpDataFrameBaseline(CursorWebSocketSendLoop loop,
1105+
long fsnAtZero, long nextWireSeq) throws Exception {
1106+
Field z = CursorWebSocketSendLoop.class.getDeclaredField("fsnAtZero");
1107+
z.setAccessible(true);
1108+
z.setLong(loop, fsnAtZero);
1109+
Field w = CursorWebSocketSendLoop.class.getDeclaredField("nextWireSeq");
1110+
w.setAccessible(true);
1111+
w.setLong(loop, nextWireSeq);
1112+
Field d = CursorWebSocketSendLoop.class.getDeclaredField("dataFrameSentThisConnection");
1113+
d.setAccessible(true);
1114+
d.setBoolean(loop, true);
1115+
}
1116+
1117+
private static long getLongField(CursorWebSocketSendLoop loop, String name) throws Exception {
1118+
Field f = CursorWebSocketSendLoop.class.getDeclaredField(name);
1119+
f.setAccessible(true);
1120+
return f.getLong(loop);
1121+
}
1122+
10611123
private static long[] txns(long... v) {
10621124
return v;
10631125
}

0 commit comments

Comments
 (0)