Skip to content

Commit 8dd7723

Browse files
glasstigerclaude
andcommitted
Harden delta symbol-dict recovery and NACK gating
Two fixes surfaced by review of the delta symbol-dictionary change. Recover oversized persisted symbols (C1). PersistedSymbolDict.openExisting rejected any entry larger than a fixed 1 MB ceiling as "oversized" and truncated the dictionary at that id, but the write path (appendSymbol/appendSymbols) caps nothing. A symbol value over 1 MB therefore persisted fine, yet a normal process-crash recovery dropped it and every higher id; the send loop's replay guard then fired a spurious "host crash / resend required" terminal -- a store-and-forward process-crash-durability violation on a reachable input (nothing caps symbol value length). Drop the fixed per-entry ceiling and bound entries only by the file length, which is the actual corruption guard and was already present. Keep the length in a long so a corrupt multi-gigabyte varint cannot wrap an int back under the check. The write and read paths now agree, so a legitimately persisted large symbol recovers. Guard the catch-up NACK pre-send gate (C2). handleServerRejection keys its pre-send branch off dataFrameSentThisConnection, because the symbol catch-up advances nextWireSeq without sending a data frame. That branch had no regression test: every existing NACK test sets both flags together, so reverting the gate to the old nextWireSeq-based predicate left the suite green. Add the server-NACK twin of testNonOrderlyCloseAfterOnlyCatchUpDoesNotStrike -- a WRITE_ERROR NACK of a catch-up frame must take the pre-send path (surface and recycle, no strike), not escalate a transient outage to a producer-fatal PROTOCOL_VIOLATION terminal. Both regression tests fail with their production line reverted and pass with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fc8b4ba commit 8dd7723

3 files changed

Lines changed: 93 additions & 7 deletions

File tree

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
@@ -89,9 +89,6 @@ public final class PersistedSymbolDict implements QuietCloseable {
8989
static final int FILE_MAGIC = 0x31445953; // 'SYD1' little-endian
9090
static final int HEADER_SIZE = 8;
9191
static final byte VERSION = 1;
92-
// Guards against a hostile/corrupt varint length driving a huge allocation
93-
// or a runaway parse. Symbols are short; this is a generous ceiling.
94-
private static final int MAX_ENTRY_LEN = 1 << 20;
9592
private static final Logger LOG = LoggerFactory.getLogger(PersistedSymbolDict.class);
9693
private final int fd;
9794
private long appendOffset;
@@ -337,12 +334,20 @@ private static PersistedSymbolDict openExisting(String filePath, long fileLen) {
337334
if (vr == null) {
338335
break; // torn length varint
339336
}
340-
int entryLen = (int) vr[0];
337+
long entryLen = vr[0];
341338
int next = (int) vr[1];
342-
if (entryLen < 0 || entryLen > MAX_ENTRY_LEN || (long) next + entryLen > len) {
343-
break; // torn/oversized entry -- self-healing tail
339+
// The file length is the sole sanity bound: a length that runs past
340+
// the file is a torn/incomplete trailing entry and stops the parse
341+
// (self-healing tail). There is deliberately NO fixed per-entry
342+
// ceiling -- the write path (appendSymbol/appendSymbols) applies none
343+
// either, so a legitimately persisted large symbol must recover here,
344+
// not be truncated and then trip the send loop's "resend required"
345+
// guard on a normal process-crash recovery. entryLen stays a long so a
346+
// corrupt multi-gigabyte length cannot wrap an int back under the check.
347+
if ((long) next + entryLen > len) {
348+
break; // torn/incomplete trailing entry -- self-healing tail
344349
}
345-
pos = next + entryLen;
350+
pos = next + (int) entryLen;
346351
count++;
347352
}
348353
int entriesLen = pos - HEADER_SIZE;

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,42 @@ public void testNonOrderlyCloseAfterOnlyCatchUpDoesNotStrike() throws Exception
279279
});
280280
}
281281

282+
@Test
283+
public void testNonOrderlyRejectionAfterOnlyCatchUpDoesNotStrike() throws Exception {
284+
// C2 regression: the server-NACK twin of
285+
// testNonOrderlyCloseAfterOnlyCatchUpDoesNotStrike. handleServerRejection's
286+
// pre-send gate keys off dataFrameSentThisConnection, NOT nextWireSeq -- the
287+
// dictionary catch-up advances nextWireSeq WITHOUT sending a data frame. A
288+
// server NACK of a catch-up frame (nextWireSeq>0, no data frame sent) must
289+
// take the pre-send path (surface + recycle, no strike), not the post-send
290+
// poison-strike path. Pre-fix (gate on highestSent >= 0, i.e. nextWireSeq>0)
291+
// each catch-up NACK strikes the never-sent head frame; MAX_REJECTIONS such
292+
// strikes escalate a TRANSIENT outage to a producer-fatal PROTOCOL_VIOLATION
293+
// terminal -- exactly what store-and-forward's retry-forever contract forbids.
294+
// WRITE_ERROR (RETRIABLE) is the discriminating category: it DOES accrue
295+
// strikes on the post-send path (see testNackRecycleIsPacedAgainstHealthyServer),
296+
// so a regressed gate escalates here and checkError() throws.
297+
TestUtils.assertMemoryLeak(() -> {
298+
List<WebSocketClient> clients = new ArrayList<>();
299+
try (CursorSendEngine engine = newEngine()) {
300+
appendFrames(engine, 2);
301+
CursorWebSocketSendLoop loop = newDurableLoop(engine, clients);
302+
for (int i = 0; i < MAX_REJECTIONS + 2; i++) {
303+
// Model the catch-up: nextWireSeq advanced, but NO data frame
304+
// sent. The pre-send recycle (swapClient) resets nextWireSeq, so
305+
// re-apply before each NACK (mirrors the onClose twin).
306+
setCatchUpWireSeqOnly(loop, 2);
307+
deliverRetriableNack(loop, 1, "disk full");
308+
}
309+
// No strike was ever charged, so nothing escalated: the loop stays
310+
// retriable and the producer-facing error latch is clear.
311+
loop.checkError();
312+
} finally {
313+
closeAll(clients);
314+
}
315+
});
316+
}
317+
282318
@Test
283319
public void testNackRecycleIsPacedAgainstHealthyServer() throws Exception {
284320
// A reachable, healthy server that NACKs the head frame (RETRIABLE)

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import io.questdb.client.cutlass.qwp.client.GlobalSymbolDictionary;
2828
import io.questdb.client.cutlass.qwp.client.sf.cursor.PersistedSymbolDict;
2929
import io.questdb.client.std.ObjList;
30+
import io.questdb.client.test.tools.TestUtils;
3031
import org.junit.Assert;
3132
import org.junit.Test;
3233

@@ -198,6 +199,50 @@ public void testEmptySymbolRoundTrips() throws Exception {
198199
});
199200
}
200201

202+
@Test
203+
public void testLargeSymbolRoundTripsAcrossReopen() throws Exception {
204+
// C1 regression: the write path caps nothing, so a symbol larger than the
205+
// old fixed 1 MB read ceiling must still recover intact. Before the fix,
206+
// appendSymbol wrote the oversized entry but openExisting rejected it as
207+
// "oversized", truncated the dictionary at that id (dropping it and every
208+
// higher id), and a normal process-crash recovery then hard-failed with a
209+
// spurious "host crash / resend required" terminal -- defeating store-and-
210+
// forward's process-crash durability for large symbols. The file length is
211+
// now the only bound, so the write and read paths agree.
212+
assertMemoryLeak(() -> {
213+
Path dir = Files.createTempDirectory("qwp-symdict");
214+
try {
215+
// Just over the old 1 << 20 (1 MB) ceiling.
216+
String big = TestUtils.repeat("x", (1 << 20) + 17);
217+
PersistedSymbolDict d = PersistedSymbolDict.open(dir.toString());
218+
try {
219+
d.appendSymbol("before");
220+
d.appendSymbol(big);
221+
d.appendSymbol("after");
222+
Assert.assertEquals(3, d.size());
223+
} finally {
224+
d.close();
225+
}
226+
227+
// Recovery must load ALL three; pre-fix the reopen truncated at the
228+
// big entry and came back with size 1 (only "before" survived).
229+
PersistedSymbolDict re = PersistedSymbolDict.open(dir.toString());
230+
try {
231+
Assert.assertEquals("large entry must survive recovery, not be truncated",
232+
3, re.size());
233+
ObjList<String> s = re.readLoadedSymbols();
234+
Assert.assertEquals("before", s.getQuick(0));
235+
Assert.assertEquals(big, s.getQuick(1));
236+
Assert.assertEquals("after", s.getQuick(2));
237+
} finally {
238+
re.close();
239+
}
240+
} finally {
241+
rmDir(dir);
242+
}
243+
});
244+
}
245+
201246
@Test
202247
public void testRemoveOrphanDeletesFile() throws Exception {
203248
assertMemoryLeak(() -> {

0 commit comments

Comments
 (0)