Skip to content

Commit bbc01a8

Browse files
glasstigerclaude
andcommitted
Cover split-preflight and torn-dict guard gaps
Address review findings on the delta symbol-dictionary change. Tests (close two coverage gaps): - SelfSufficientFramesTest pins the split-flush pre-flight baseline advance: a large-delta two-table split whose second frame fits only with an empty delta. Removing the simBaseline advance mis-sizes that frame with the delta and wrongly rejects a shippable batch. - CursorWebSocketSendLoopOrphanTailTest pins that a zero-count delta frame anchors recoveredMaxSymbolId at its baseline, not 0. Skipping it would under-strand a torn dictionary and risk a silent id shift. Docs (correct load-bearing recovery semantics): - seedGlobalDictionaryFromPersisted no longer claims the drainer always self-heals: it does so only when a surviving frame straddles the tear; a higher-baseline survivor quarantines the slot, a deliberate conservative over-strand. - maxSymbolDeltaEnd documents that a zero-count delta frame contributes its deltaStart, not 0. - The catch-up cap-gap budget documents that a transient reconnect never increments or resets the counter (only a successful catch-up resets it), so a transient cannot burn the terminal budget. Hardening: - writeVarint and putVarint assert a non-negative value: the signed loop writes one truncated byte for a negative long while varintSize returns 10. - PersistedSymbolDict.open diverts a file at exactly Integer.MAX_VALUE to a clean re-create instead of a doomed 2GB malloc (> becomes >=). - readLoadedSymbols bounds its varint decode (shift > 35) like its sibling parsers. Production behavior changes are limited to the two varint asserts, the >= boundary, and the defensive varint bound; the rest is tests and documentation. Full delta-dict suite green on JDK 25 with -ea. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0c0b9dc commit bbc01a8

7 files changed

Lines changed: 200 additions & 33 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,14 @@ public static int varintSize(long value) {
8181
* {@code addr} and returns the address just past the last byte. The canonical
8282
* raw-address varint writer shared by the SF cursor's persisted dictionary and
8383
* catch-up frame builder.
84+
* <p>
85+
* {@code value} must be non-negative: the signed {@code value > 0x7F} loop emits
86+
* a SINGLE truncated byte for a negative long, whereas {@link #varintSize}
87+
* returns 10 for it -- a size/write mismatch that would corrupt the stream. All
88+
* callers pass ids/lengths/counts (non-negative); the assert pins that contract.
8489
*/
8590
public static long writeVarint(long addr, long value) {
91+
assert value >= 0 : "unsigned LEB128 varint requires a non-negative value: " + value;
8692
while (value > 0x7F) {
8793
Unsafe.getUnsafe().putByte(addr++, (byte) ((value & 0x7F) | 0x80));
8894
value >>>= 7;
@@ -320,6 +326,7 @@ public void putUtf8(CharSequence value) {
320326
*/
321327
@Override
322328
public void putVarint(long value) {
329+
assert value >= 0 : "unsigned LEB128 varint requires a non-negative value: " + value;
323330
ensureCapacity(10); // max varint bytes
324331
long addr = bufferPtr + position;
325332
while (value > 0x7F) {

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3876,9 +3876,23 @@ private void resetSymbolDictStateForNewConnection() {
38763876
* mirror, leaving only this producer diverged. Detect it here -- the surviving
38773877
* frames reference an id at or beyond the recovered dictionary size -- and fail
38783878
* clean: the affected data must be resent, matching the design's torn-dict
3879-
* "resend required" contract. The recovered data is not lost; the background
3880-
* drainer still drains this slot (its mirror self-heals from the frames). Only a
3881-
* host crash reaches this -- a process crash keeps the page cache, so the
3879+
* "resend required" contract.
3880+
* <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
38823896
* write-ahead ordering keeps the dictionary a superset of the frames.
38833897
*/
38843898
private void seedGlobalDictionaryFromPersisted(PersistedSymbolDict pd) {

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

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -133,17 +133,30 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
133133
*/
134134
public static final long DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS = 5_000L;
135135
private static final Logger LOG = LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
136-
// Settle budget for the symbol-dict catch-up cap gap: how many CONSECUTIVE
137-
// reconnect attempts may find a single dictionary entry too large for the fresh
138-
// server's advertised batch cap before the sender latches a terminal. A
139-
// homogeneous cluster never trips it -- an entry that fit its data frame under a
140-
// cap always fits its bare catch-up frame under that same cap -- so this only
141-
// affects a heterogeneous / rolling-cap cluster, where a failover to a
142-
// smaller-cap node can hit it for an entry an earlier node accepted. Retrying
143-
// rides out the transient window until a larger-cap node returns; only a
144-
// persistent gap (every reachable node too small for this many attempts) latches
145-
// terminal, matching the orphan drainer's DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS.
146-
// A successful catch-up resets the counter (see sendDictCatchUp).
136+
// Settle budget for the symbol-dict catch-up cap gap: how many cap-gap attempts
137+
// -- catch-ups that reached a fresh server and found a single dictionary entry
138+
// too large for its advertised batch cap -- may occur, with no intervening
139+
// SUCCESSFUL catch-up, before the sender latches a terminal. This is a SANCTIONED
140+
// terminal (a genuine cluster batch-size capability gap), the connect-time analog
141+
// of the orphan drainer's durable-ack capability gap
142+
// (DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS). A homogeneous cluster never trips
143+
// it -- an entry that fit its data frame under a cap always fits its bare catch-up
144+
// frame under that same cap -- so it only affects a heterogeneous / rolling-cap
145+
// cluster, where a failover to a smaller-cap node can hit it for an entry an
146+
// earlier node accepted. Retrying rides out the transient window until a
147+
// larger-cap node returns; only a persistent gap (this many cap gaps with no
148+
// successful catch-up in between) latches.
149+
//
150+
// Budget accounting (satisfies "a transient must never burn the terminal
151+
// budget"): catchUpCapGapAttempts increments ONLY inside sendDictCatchUp when a
152+
// node is reached and an entry is oversized, and resets ONLY when a catch-up fully
153+
// succeeds. A TRANSIENT reconnect (connect refuse, upgrade/role failure) never
154+
// reaches the catch-up, so it NEITHER increments nor resets the counter -- it only
155+
// lengthens the wall-clock settle window. The terminal therefore always requires
156+
// this many GENUINE cap gaps; a transient can never inflate it. Deliberately NOT
157+
// reset on a mere successful RECONNECT: a reconnect to the small-cap node itself
158+
// produces the cap gap, so resetting there would stop a persistent gap from ever
159+
// latching -- the reset must gate on a successful CATCH-UP, not on connecting.
147160
private static final int MAX_CATCHUP_CAP_GAP_ATTEMPTS = 16;
148161
// Hard ceiling for the lifetime-monotonic sent-dictionary mirror. The mirror
149162
// fields are int, so it cannot exceed Integer.MAX_VALUE bytes; reaching even
@@ -218,11 +231,12 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
218231
// for the connection's lifetime (a reconnect may need the whole dictionary at
219232
// any moment), so it cannot be dropped; it is an intentional cost of the feature.
220233
private final boolean deltaDictEnabled;
221-
// Consecutive reconnect attempts whose symbol-dict catch-up found an entry too
222-
// large for the fresh server's batch cap (see MAX_CATCHUP_CAP_GAP_ATTEMPTS). A
223-
// successful catch-up resets it; it is NOT reset per connection -- it measures
224-
// the cap-gap episode across reconnects so a persistent gap eventually latches.
225-
// I/O-thread-only.
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.
226240
private int catchUpCapGapAttempts;
227241
// True once a real ring frame (data or commit) has been sent on the CURRENT
228242
// connection, as opposed to only the dictionary catch-up. The catch-up

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -523,9 +523,16 @@ public long findLastFrameFsnWithoutPayloadFlag(int flagsOffset, int flagMask, in
523523
}
524524

525525
/**
526-
* Highest {@code deltaStart + deltaCount} (one past the highest symbol id) that
527-
* any symbol-dict delta frame in this segment references, or {@code 0} when no
528-
* such frame carries a symbol. Read-only walk over the recovered frames, used
526+
* Highest {@code deltaStart + deltaCount} (one past the highest symbol id) any
527+
* delta-flagged frame in this segment carries, or {@code 0} only when NO
528+
* delta-flagged frame is present. A frame with {@code deltaCount == 0} (a commit
529+
* or a symbol-reusing frame -- the encoder always sets the delta flag) still
530+
* contributes its {@code deltaStart}, i.e. the producer's baseline at encode
531+
* time, NOT 0. That is deliberate: it anchors the torn-dict guard at that
532+
* baseline so a dictionary torn below it is detected -- a conservative
533+
* over-strand (it may over-reject a frame whose rows reference only surviving
534+
* ids), never an under-strand that could silently shift the dense id map.
535+
* Read-only walk over the recovered frames, used
529536
* once at recovery to detect a persisted {@code .symbol-dict} torn (host crash,
530537
* out-of-order page loss) below the ids the surviving frames still reference: if
531538
* the max here reaches at or beyond the recovered dictionary size, a resuming

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

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -170,17 +170,19 @@ public static PersistedSymbolDict open(String slotDir) {
170170
public static PersistedSymbolDict open(FilesFacade ff, String slotDir) {
171171
String filePath = slotDir + "/" + FILE_NAME;
172172
long existing = ff.exists(filePath) ? ff.length(filePath) : -1L;
173-
// A dictionary that legitimately grew past Integer.MAX_VALUE cannot be
174-
// reopened: openExisting reads it into ONE int-sized native buffer, and
175-
// the (int) cast of a >2GB length is either negative (malloc rejects it),
176-
// exactly zero (getInt then reads 4 bytes past a zero-size allocation), or
177-
// a small positive prefix (whose validLen < len branch would then
178-
// DESTRUCTIVELY truncate the multi-GB file). Recreate empty instead --
179-
// fail-clean, exactly like every other unreadable-file case here, so the
180-
// sender falls back to full self-sufficient frames. Reaching this needs
181-
// ~100M+ distinct symbols on one slot (far past realistic symbol
182-
// cardinality); the guard keeps the read/write size boundary safe anyway.
183-
if (existing > Integer.MAX_VALUE) {
173+
// A dictionary that grew to or past Integer.MAX_VALUE cannot be reopened:
174+
// openExisting reads it into ONE int-sized native buffer. PAST 2GiB the
175+
// (int) cast is either negative (malloc rejects it), exactly zero (getInt
176+
// then reads 4 bytes past a zero-size allocation), or a small positive prefix
177+
// (whose validLen < len branch would then DESTRUCTIVELY truncate the multi-GB
178+
// file); AT exactly Integer.MAX_VALUE the cast is exact but the ~2GB malloc is
179+
// doomed to OutOfMemoryError. The >= guard short-circuits every case to a
180+
// clean re-create instead of the doomed allocation -- fail-clean, exactly like
181+
// every other unreadable-file case here, so the sender falls back to full
182+
// self-sufficient frames. Reaching this needs ~100M+ distinct symbols on one
183+
// slot (far past realistic symbol cardinality); the guard keeps the read/write
184+
// size boundary safe anyway.
185+
if (existing >= Integer.MAX_VALUE) {
184186
LOG.warn("symbol dict {} too large ({} bytes) to reopen; recreating empty", filePath, existing);
185187
return openFresh(ff, filePath);
186188
}
@@ -463,6 +465,13 @@ public ObjList<String> readLoadedSymbols() {
463465
break;
464466
}
465467
shift += 7;
468+
if (shift > 35) {
469+
// Bound the varint like decodeVarint / appendRawEntries /
470+
// readVarintAt: a canonical length is <= 5 bytes. open() already
471+
// CRC-validated these bytes, so this is defensive only; the
472+
// p + len > limit check below then rejects the over-long run.
473+
break;
474+
}
466475
}
467476
if (p + len > limit) {
468477
break; // defensive: torn tail (should not happen past parse in open)

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,75 @@ public void testFileModeSplitPersistsDictBeforePublish() throws Exception {
381381
}
382382
}
383383

384+
@Test
385+
public void testSplitPreflightAdvancesBaselineSoLaterFramesArentSizedWithTheDelta() throws Exception {
386+
// Regression for the split pre-flight baseline advance in flushPendingRowsSplit
387+
// (the "Mirror advanceSentMaxSymbolId" step). Only the FIRST split frame ships
388+
// the batch's symbol-dict delta; the rest ship an EMPTY delta and reference ids
389+
// the first frame already registered. The pre-flight size pass must advance
390+
// simBaseline after the first table so it STOPS adding combinedDeltaEntriesLen
391+
// to the later frames' estimated sizes. Without that advance, a later table
392+
// whose real (empty-delta) frame fits the cap is mis-estimated as still carrying
393+
// the whole delta and wrongly rejected with "single table batch too large" --
394+
// discarding a legitimately shippable batch (fail-closed data loss).
395+
//
396+
// Shape (memory mode, delta enabled): a LARGE combined delta (two ~64-char
397+
// symbols) rides only the first split frame. The first table (added first, so
398+
// the first split frame) has a tiny body, so delta + body1 fits the cap. The
399+
// second table has a big body: body2 alone fits the cap, but delta + body2 does
400+
// NOT. The real code splits and ships both frames; the un-advanced pre-flight
401+
// would size the second frame WITH the delta and throw. Table order is
402+
// insertion order (CharSequenceObjHashMap.keys()), so t1 is the delta frame.
403+
assertMemoryLeak(() -> {
404+
CapturingHandler handler = new CapturingHandler();
405+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
406+
server.setAdvertisedMaxBatchSize(200);
407+
int port = server.getPort();
408+
server.start();
409+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
410+
411+
// Two long symbols => a large combined delta section (~130 bytes) that
412+
// rides ONLY the first split frame. The symbol STRINGS live in the delta,
413+
// not in either table body (the body carries only the varint global id).
414+
String longSymA = new String(new char[64]).replace('\0', 'a');
415+
String longSymB = new String(new char[64]).replace('\0', 'b');
416+
String bigPad = new String(new char[100]).replace('\0', 'x');
417+
// auto_flush off so both rows batch into one flush (byte-based auto-flush
418+
// is otherwise clamped under the cap and would flush before the split).
419+
try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port
420+
+ ";auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000;")) {
421+
// t1 (added first -> first split frame): carries the whole delta but a
422+
// tiny body, so delta + body1 fits the 200-byte cap.
423+
sender.table("t1").symbol("s", longSymA).longColumn("v", 1L).atNow();
424+
// t2 (second split frame): empty delta but a big body. body2 alone
425+
// fits the cap; delta + body2 does NOT -- the mis-size the advance
426+
// prevents.
427+
sender.table("t2").symbol("s", longSymB).stringColumn("p", bigPad).longColumn("v", 2L).atNow();
428+
// Must NOT throw: with the baseline advanced, t2's frame is sized
429+
// WITHOUT the delta and fits. A broken advance throws "too large" here.
430+
sender.flush();
431+
waitFor(() -> handler.batches.size() >= 2, 5_000);
432+
}
433+
434+
Assert.assertEquals("the batch must split into 2 frames, neither spuriously rejected",
435+
2, handler.batches.size());
436+
byte[] f1 = handler.batches.get(0);
437+
byte[] f2 = handler.batches.get(1);
438+
// First split frame ships the whole delta (both new symbols, ids 0 and 1).
439+
Assert.assertEquals("first split frame deltaStart must be 0",
440+
0, readVarint(f1, DELTA_START_OFFSET));
441+
Assert.assertEquals("first split frame ships both new symbols",
442+
2, readVarint(f1, DELTA_START_OFFSET + 1));
443+
// Second split frame carries an EMPTY delta above the advanced baseline --
444+
// the whole point: it is not re-sized (or re-sent) with the delta.
445+
Assert.assertEquals("second split frame deltaStart must be 2 (baseline advanced)",
446+
2, readVarint(f2, DELTA_START_OFFSET));
447+
Assert.assertEquals("second split frame carries no new symbols",
448+
0, readVarint(f2, DELTA_START_OFFSET + 1));
449+
}
450+
});
451+
}
452+
384453
private static int readVarint(byte[] buf, int offset) {
385454
// Simple unsigned varint decode — sufficient for small values.
386455
int result = 0;

0 commit comments

Comments
 (0)