Skip to content

Commit 4c10246

Browse files
glasstigerclaude
andcommitted
Pin catch-up id tiling and fix stale comments
The split catch-up tests counted frames but never read their bytes, so a chunk walk shipping the right NUMBER of frames while overlapping or skipping ids passed them all. That is the silent-NULL corruption vector: the server null-pads a gapped delta, and a row referencing a padded id lands a NULL symbol with no error anywhere. Mutating the walk's start-id advance proves the gap -- the alignment suite stayed 17/17 green while only the 25-second end-to-end test noticed. CatchUpCapturingClient now keeps each frame's bytes, joining the two slices of a multipart send, and assertCatchUpReassembles folds them through QwpWireTestUtils.accumulateDeltaDictionary -- the decoder the end-to-end handler already uses -- then compares per id. Comparing content rather than ranges catches overlap, gap and shift alike, and each frame is checked against the advertised cap. QwpWireTestUtils and that one method turn public for the sf.cursor package; the rest of the class stays package-private. Add three tests: a three-way split whose chunks must tile [0, n) exactly; an empty-dictionary reconnect that must ship no catch-up frame, using cap 0 so the guard rather than an empty walk holds the count at zero; and a variable-width split, the only test that catches a walk assuming a uniform entry stride. Cover multi-byte UTF-8 on the persist path, which nothing exercised: every symbol in these suites was ASCII, where a symbol's UTF-8 byte length and its char count agree. Replacing Utf8s.utf8Bytes with String.length leaves DeltaDictRecoveryTest 23/23 green while the new round-trip test fails on the truncated tail. The literals carry a runtime self-check so the test cannot decay into an ASCII one should the file ever lose its encoding. Drop a comment block left stranded above WebSocketResponse when its field was removed, and rewrite two that described an entry-end index this class never had. Correct the surviving Invariant B text: the reconnect budget reaches connectWithRetry from ensureConnected's SYNC branch and BackgroundDrainer's capability-gap deadline, never buildAndConnect and never this loop. Tear the torn-dict guard test down recursively. It builds the slot layout under <dir>/default, which the flat helper cannot remove, so every run left the whole tree behind. Production behaviour is unchanged: every main/ hunk is comment text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ece7817 commit 4c10246

5 files changed

Lines changed: 264 additions & 20 deletions

File tree

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

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -249,11 +249,6 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
249249
private final ReconnectFactory reconnectFactory;
250250
private final long reconnectInitialBackoffMillis;
251251
private final long reconnectMaxBackoffMillis;
252-
// Retained for constructor symmetry and passed in by callers, but NOT
253-
// consulted by the background loop: Invariant B removed the wall-clock
254-
// give-up from connectLoop. The budget still bounds the blocking (non-lazy)
255-
// initial connect via QwpWebSocketSender -> connectWithRetry, which takes it
256-
// as an explicit argument rather than reading this field.
257252
private final WebSocketResponse response = new WebSocketResponse();
258253
private final ResponseHandler responseHandler = new ResponseHandler();
259254
private final CountDownLatch shutdownLatch = new CountDownLatch(1);
@@ -761,10 +756,14 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
761756
// re-registers its dictionary from id 0 as it replays, so seeding the mirror would buy
762757
// nothing and cost a catch-up frame on every connection -- full-dict slots must stay
763758
// catch-up-free.
764-
// The prefix seed above may be borrowed, while the entry-end index is always
765-
// loop-owned native memory. Build/extend both inside one cleanup boundary:
766-
// any allocation failure propagates out of the constructor before a caller
767-
// can own the half-built loop, so nothing else could release them.
759+
// The prefix seed above may still be borrowed from PersistedSymbolDict.
760+
// Extending it inside this cleanup boundary is what makes that safe:
761+
// ensureSentDictCapacity copy-on-writes a borrowed prefix into loop-OWNED
762+
// native memory, so from that point a failure would leak it -- and an
763+
// allocation failure here propagates out of the constructor before any
764+
// caller can own the half-built loop, leaving nothing else able to
765+
// release it. releaseSentDictBytes() in the catch frees exactly that
766+
// mirror, and only when this loop owns it.
768767
try {
769768
if (engine.recoveredMaxSymbolDeltaStart() > 0L) {
770769
int baseline = sentDictCount;
@@ -792,10 +791,12 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
792791
}
793792
}
794793
}
795-
// The entry-ends index is NOT built here. sendDictCatchUp is its only
796-
// reader and builds it on demand, so a recovered slot that drains without
797-
// ever reconnecting -- the normal case -- never pays the O(n) walk nor
798-
// retains the 4-bytes-per-symbol index.
794+
// No per-entry offset index is derived here, and none is kept anywhere:
795+
// the mirror carries its own framing ([len varint][utf8] per entry), so
796+
// sendDictCatchUp -- its only reader -- recovers each entry's span with a
797+
// running pointer as it walks. A recovered slot that drains without ever
798+
// reconnecting, the normal case, therefore never pays that O(n) walk and
799+
// carries no index memory for it.
799800
} catch (Throwable t) {
800801
releaseSentDictBytes();
801802
throw t;
@@ -1575,9 +1576,12 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
15751576
// drainers. Foreground senders retry them from asynchronous startup onward
15761577
// so a credential or cluster capability rotation cannot stop the producer. SF
15771578
// exhaustion is surfaced to the PRODUCER as append backpressure, never
1578-
// here. reconnect_max_duration_millis is intentionally NOT consulted: it
1579-
// bounds only the blocking (non-lazy) initial connect in
1580-
// QwpWebSocketSender.buildAndConnect, never this background loop.
1579+
// here. reconnect_max_duration_millis is intentionally NOT consulted by
1580+
// THIS loop. Its holders pass it explicitly where it does apply: the
1581+
// blocking (non-lazy) initial connect hands it to connectWithRetry (see
1582+
// QwpWebSocketSender.ensureConnected, the SYNC branch), and
1583+
// BackgroundDrainer converts it into the durable-ack capability-gap
1584+
// budget. Neither bounds this loop's steady-state reconnect.
15811585
long backoffMillis = reconnectInitialBackoffMillis;
15821586
if (paceFirstAttemptMillis > 0 && running) {
15831587
// NACK-initiated recycle against a reachable server: pace the

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,18 @@
3232
import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.FLAG_DELTA_SYMBOL_DICT;
3333
import static io.questdb.client.cutlass.qwp.protocol.QwpConstants.HEADER_SIZE;
3434

35-
final class QwpWireTestUtils {
35+
public final class QwpWireTestUtils {
3636

37-
static void accumulateDeltaDictionary(byte[] frame, List<String> dictionary) {
37+
/**
38+
* Folds one frame's symbol-dict delta into {@code dictionary}, exactly as the
39+
* server does -- including padding a gap with nulls rather than rejecting it,
40+
* so a caller can observe a gap instead of having it hidden. Public so the
41+
* store-and-forward send-loop tests in the {@code sf.cursor} package can
42+
* reassemble captured catch-up frames through the SAME decoder the
43+
* end-to-end tests' server handler uses, rather than hand-rolling a second
44+
* one that could drift from it.
45+
*/
46+
public static void accumulateDeltaDictionary(byte[] frame, List<String> dictionary) {
3847
if (!hasDelta(frame)) {
3948
return;
4049
}

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

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import io.questdb.client.network.PlainSocketFactory;
3535
import io.questdb.client.std.MemoryTag;
3636
import io.questdb.client.std.Unsafe;
37+
import io.questdb.client.test.cutlass.qwp.client.QwpWireTestUtils;
3738
import io.questdb.client.test.tools.TestUtils;
3839
import org.junit.After;
3940
import org.junit.Before;
@@ -166,25 +167,31 @@ public void testSplitCatchUpStagesOnlyPrefixAcrossReconnects() throws Exception
166167
try (CursorSendEngine engine = newEngine()) {
167168
CursorWebSocketSendLoop loop = newLoop(engine, client);
168169
try {
169-
seedMirror(loop, TestUtils.repeat("x", 3_000), TestUtils.repeat("y", 3_000));
170+
String symX = TestUtils.repeat("x", 3_000);
171+
String symY = TestUtils.repeat("y", 3_000);
172+
seedMirror(loop, symX, symY);
170173

171174
invokeSetWireBaselineWithCatchUp(loop, 0L);
172175
assertEquals("the small cap must split the dictionary", 2, client.framesSent);
173176
assertEquals("catch-up must send the symbol bytes as a second payload slice",
174177
2, client.multipartFramesSent);
175178
assertEquals("the split chunks need one small prefix buffer",
176179
1, loop.catchUpFrameGrowthCount());
180+
// Splitting is only correct if the chunks reassemble gap-free.
181+
assertCatchUpReassembles(client, symX, symY);
177182

178183
client.cap = 7_000;
179184
invokeSetWireBaselineWithCatchUp(loop, 0L);
180185
assertEquals("the larger cap must combine the dictionary", 3, client.framesSent);
181186
assertEquals("combining symbols must not grow the prefix-only buffer",
182187
1, loop.catchUpFrameGrowthCount());
188+
assertCatchUpReassembles(client, symX, symY);
183189

184190
invokeSetWireBaselineWithCatchUp(loop, 0L);
185191
assertEquals("the next reconnect sends one combined frame", 4, client.framesSent);
186192
assertEquals("the prefix buffer must be reused across reconnects",
187193
1, loop.catchUpFrameGrowthCount());
194+
assertCatchUpReassembles(client, symX, symY);
188195
} finally {
189196
// assertMemoryLeak verifies that close releases the retained buffer.
190197
loop.close();
@@ -193,6 +200,113 @@ public void testSplitCatchUpStagesOnlyPrefixAcrossReconnects() throws Exception
193200
});
194201
}
195202

203+
@Test
204+
public void testSplitCatchUpChunksTileTheDictionaryExactly() throws Exception {
205+
// Three chunks rather than two: a start-id error that happens to cancel
206+
// out across a single boundary cannot survive two of them, and only a
207+
// multi-chunk walk exercises the accumulating "chunkStartId +=
208+
// chunkSymbols" drift. Six 100-byte symbols under a cap that fits two
209+
// per frame; the reassembly must return all six, in order, with no null.
210+
TestUtils.assertMemoryLeak(() -> {
211+
String[] symbols = new String[6];
212+
for (int i = 0; i < symbols.length; i++) {
213+
symbols[i] = TestUtils.repeat(String.valueOf((char) ('a' + i)), 100);
214+
}
215+
CatchUpCapturingClient client = new CatchUpCapturingClient(250);
216+
try (CursorSendEngine engine = newEngine()) {
217+
CursorWebSocketSendLoop loop = newLoop(engine, client);
218+
try {
219+
seedMirror(loop, symbols);
220+
invokeSetWireBaselineWithCatchUp(loop, 0L);
221+
assertEquals("the cap must force a three-way split", 3, client.framesSent);
222+
assertCatchUpReassembles(client, symbols);
223+
} finally {
224+
loop.close();
225+
}
226+
}
227+
});
228+
}
229+
230+
@Test
231+
public void testEmptyDictionaryReconnectSendsNoCatchUpFrame() throws Exception {
232+
// The sentDictCount > 0 half of setWireBaselineWithCatchUp's gate. Every
233+
// other test here seeds at least one symbol, so the empty-dictionary
234+
// branch -- a delta-enabled connection that has not registered anything
235+
// yet, i.e. every reconnect before the first symbol -- was never
236+
// exercised. Emitting a zero-entry catch-up frame there would burn a wire
237+
// sequence and, via fsnAtZero = replayStart - catchUpFrames, shift the
238+
// baseline so the first real frame no longer lands on replayStart.
239+
TestUtils.assertMemoryLeak(() -> {
240+
// cap 0 ("server advertises no cap") on purpose. Under a positive cap
241+
// sendDictCatchUp would walk zero entries and return 0 frames anyway,
242+
// so both assertions below would hold even with the sentDictCount > 0
243+
// conjunct deleted -- the test would pin nothing. Cap 0 takes the
244+
// fast path that sends one whole-dictionary frame unconditionally, so
245+
// only the guard keeps the frame count at zero.
246+
CatchUpCapturingClient client = new CatchUpCapturingClient(0);
247+
try (CursorSendEngine engine = newEngine()) {
248+
CursorWebSocketSendLoop loop = newLoop(engine, client);
249+
try {
250+
// Deliberately no seedMirror: sentDictCount stays 0.
251+
invokeSetWireBaselineWithCatchUp(loop, 7L);
252+
assertEquals("an empty dictionary must not ship a catch-up frame",
253+
0, client.framesSent);
254+
assertEquals("the baseline must stay at replayStart when nothing is re-registered",
255+
7L, loop.fsnAtZero());
256+
} finally {
257+
loop.close();
258+
}
259+
}
260+
});
261+
}
262+
263+
@Test
264+
public void testCatchUpSplitsVariableWidthEntriesWithoutDrift() throws Exception {
265+
// Every other split test here uses uniformly-sized symbols, so the chunk
266+
// walk always advances by the same stride and a span miscalculation could
267+
// cancel out. These entries are 14, 7, 11 and 22 bytes, so each hop is a
268+
// different width and the walk has to resume mid-dictionary at an
269+
// irregular boundary; the reassembly then pins the result per id.
270+
//
271+
// The symbols are multi-byte UTF-8 because that is a convenient source of
272+
// widths that differ from their char counts, and it exercises the wire
273+
// framing end to end -- NOT because sendDictCatchUp could confuse bytes
274+
// for chars: that method holds no String, char or length() at all, only
275+
// pointer arithmetic over [len varint][utf8]. The byte-vs-char hazard
276+
// lives on the persist path and is covered by
277+
// PersistedSymbolDictTest.testMultiByteUtf8SymbolsRoundTripAcrossReopen.
278+
TestUtils.assertMemoryLeak(() -> {
279+
String[] symbols = {
280+
// entry width below = 1-byte length varint + the UTF-8 bytes
281+
"températures", // 12 chars, 13 bytes -> 14
282+
"東京", // 2 chars, 6 bytes -> 7
283+
"sensor🔥", // 7 chars, 10 bytes -> 11
284+
"ascii_after_multibyte" // 21 chars, 21 bytes -> 22; a drift shows up here
285+
};
286+
// Self-check: prove the literals really are multi-byte at runtime, so
287+
// the widths stay irregular even if the source file ever loses its
288+
// encoding and they collapse to single-byte '?'.
289+
for (String s : symbols) {
290+
if (!"ascii_after_multibyte".equals(s)) {
291+
assertTrue("expected a multi-byte UTF-8 symbol, got pure ASCII: " + s,
292+
s.getBytes(StandardCharsets.UTF_8).length > s.length());
293+
}
294+
}
295+
CatchUpCapturingClient client = new CatchUpCapturingClient(80);
296+
try (CursorSendEngine engine = newEngine()) {
297+
CursorWebSocketSendLoop loop = newLoop(engine, client);
298+
try {
299+
seedMirror(loop, symbols);
300+
invokeSetWireBaselineWithCatchUp(loop, 0L);
301+
assertTrue("the cap must split the dictionary", client.framesSent > 1);
302+
assertCatchUpReassembles(client, symbols);
303+
} finally {
304+
loop.close();
305+
}
306+
}
307+
});
308+
}
309+
196310
@Test
197311
public void testTransientCatchUpSendFailureIsRetriableNotTerminal() throws Exception {
198312
// A transient wire failure WHILE shipping the catch-up (the fresh
@@ -931,6 +1045,43 @@ private CursorSendEngine newEngine() {
9311045
return new CursorSendEngine(tmpDir, 16_384);
9321046
}
9331047

1048+
/**
1049+
* Reassembles the frames captured since the last call exactly as the server
1050+
* would -- through the same {@link QwpWireTestUtils#accumulateDeltaDictionary}
1051+
* the end-to-end tests' handler uses -- and asserts the result is the seeded
1052+
* dictionary, dense and in order.
1053+
* <p>
1054+
* This is what frame counting cannot do. A catch-up split ships its chunks as
1055+
* {@code [deltaStart, deltaStart+count)} ranges that must tile {@code [0, n)}
1056+
* exactly; an off-by-one in the walk's start id keeps the frame COUNT intact
1057+
* while overlapping a range (an id silently takes its neighbour's symbol) or
1058+
* skipping one (the server null-pads it, and rows referencing it land a NULL
1059+
* symbol value). Comparing the reassembled dictionary catches all three
1060+
* shapes -- overlap, gap and shift -- because it compares content per id, not
1061+
* just the ranges.
1062+
*/
1063+
private static void assertCatchUpReassembles(CatchUpCapturingClient client, String... expected) {
1064+
List<String> rebuilt = new ArrayList<>();
1065+
for (byte[] frame : client.capturedFrames) {
1066+
// Tiling correctly is necessary but not sufficient: the split exists to
1067+
// keep every frame under the server's advertised batch cap, and a
1068+
// perfectly contiguous split that ignores the cap would otherwise only
1069+
// be caught indirectly, by a frame-count assertion.
1070+
if (client.cap > 0) {
1071+
assertTrue("catch-up frame of " + frame.length
1072+
+ " bytes exceeds the advertised cap " + client.cap,
1073+
frame.length <= client.cap);
1074+
}
1075+
QwpWireTestUtils.accumulateDeltaDictionary(frame, rebuilt);
1076+
}
1077+
client.capturedFrames.clear();
1078+
assertEquals("reassembled dictionary size", expected.length, rebuilt.size());
1079+
for (int i = 0; i < expected.length; i++) {
1080+
assertEquals("symbol at id " + i + " (a null here is a gap the server would"
1081+
+ " turn into a NULL symbol value)", expected[i], rebuilt.get(i));
1082+
}
1083+
}
1084+
9341085
// Populates the loop's native sent-dictionary mirror with {@code symbols} in
9351086
// the on-wire [len varint][utf8] layout, so setWireBaselineWithCatchUp sees a
9361087
// non-empty dictionary to re-register. loop.close() frees it.
@@ -980,6 +1131,7 @@ private static final class CatchUpCapturingClient extends WebSocketClient {
9801131
// Mutable so a test can model a rolling-cap cluster: raise it for a node that
9811132
// accepts the dictionary, lower it for a smaller-cap node that cap-gaps.
9821133
private int cap;
1134+
private final List<byte[]> capturedFrames = new ArrayList<>();
9831135
private final Runnable onCapRead;
9841136
private final boolean throwOnSend;
9851137
private int framesSent;
@@ -1016,6 +1168,7 @@ public int getServerQwpVersion() {
10161168
@Override
10171169
public void sendBinary(long dataPtr, int length) {
10181170
recordSend();
1171+
capture(dataPtr, length, 0L, 0);
10191172
}
10201173

10211174
@Override
@@ -1027,6 +1180,26 @@ public void sendBinary(
10271180
) {
10281181
multipartFramesSent++;
10291182
recordSend();
1183+
capture(firstPtr, firstLength, secondPtr, secondLength);
1184+
}
1185+
1186+
/**
1187+
* Keeps the bytes of every frame sent, joining the two slices of a
1188+
* multipart send back into the single frame the server would see.
1189+
* Counting frames alone cannot detect the failure that matters here: an
1190+
* off-by-one in the chunk walk's start id ships the same NUMBER of
1191+
* frames while overlapping or skipping ids, and the server null-pads a
1192+
* skipped id into a silent NULL symbol.
1193+
*/
1194+
private void capture(long firstPtr, int firstLength, long secondPtr, int secondLength) {
1195+
byte[] frame = new byte[firstLength + secondLength];
1196+
for (int i = 0; i < firstLength; i++) {
1197+
frame[i] = Unsafe.getUnsafe().getByte(firstPtr + i);
1198+
}
1199+
for (int i = 0; i < secondLength; i++) {
1200+
frame[firstLength + i] = Unsafe.getUnsafe().getByte(secondPtr + i);
1201+
}
1202+
capturedFrames.add(frame);
10301203
}
10311204

10321205
private void recordSend() {

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ public void setUp() {
7272

7373
@After
7474
public void tearDown() {
75-
TestUtils.removeTmpDir(sfDir);
75+
// Recursive: this test builds the store-and-forward slot layout
76+
// (<dir>/default/... plus <dir>/.slot-locks/...), and the flat variant
77+
// cannot remove a non-empty subdirectory -- it also discards its result,
78+
// so the whole tree survived every run unnoticed.
79+
TestUtils.removeTmpDirRec(sfDir);
7680
}
7781

7882
@Test

0 commit comments

Comments
 (0)