Skip to content

Commit c065ad4

Browse files
glasstigerclaude
andcommitted
Stop split-flush stranding a deferred prefix
flushPendingRowsSplit published each table's frame one at a time (deferred, uncommitted) and only checked a frame against the server batch cap after encoding it. An oversized non-first table therefore threw after an earlier table's frame was already on the ring, where a later commit delivered it as a partial batch -- while resetTableBuffersAfterFlush discarded every source row. The caller saw a failure for a batch that had partly committed. Pre-flight every split frame's size before publishing any of them, so the split is all-or-nothing: either every frame fits and all publish, or none publish and it throws with nothing stranded. simBaseline mirrors advanceSentMaxSymbolId, so a measured size equals the frame the publish loop builds; the pass is read-only on the table buffers and touches no delta/persist state. The cost is a second encode pass over the split batch, already the exceptional large-batch path. testOversizedTableSplitStrandsNothing reproduces the stranding -- it fails on the old code with the earlier frame reaching the server -- and passes with the pre-flight. Also add testReconnectPreservesMonotonicDeltaBaseline: it pins that the producer's sent-symbol watermark survives a reconnect, asserting the first post-reconnect data frame carries a delta above the baseline (deltaStart >= 1) rather than re-shipping the whole dictionary from 0. The existing catch-up tests assert only that the final dictionary is complete, which a reset-then-redefine also satisfies. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6cc1307 commit c065ad4

3 files changed

Lines changed: 174 additions & 15 deletions

File tree

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

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3552,16 +3552,48 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
35523552
LOG.debug("Splitting flush across multiple messages [serverMaxBatchSize={}, defer={}]", serverMaxBatchSize, deferCommit);
35533553
}
35543554

3555-
// Collect non-empty table indices so we know which is last.
3555+
// Collect non-empty table indices so we know which is last, AND pre-flight
3556+
// every split frame's size BEFORE publishing any of them. The split hands
3557+
// frames to the ring one at a time (all but the last deferred -- appended but
3558+
// uncommitted); if a later table's frame were only found oversized
3559+
// mid-publish, the already-published prefix would strand on the ring, a
3560+
// subsequent commit would deliver it as a partial batch, and
3561+
// resetTableBuffersAfterFlush would discard every source row -- a partial
3562+
// commit the caller was told (by the throw) had failed. Checking all sizes up
3563+
// front makes the split all-or-nothing: either every frame fits and all
3564+
// publish, or none publish and we throw with nothing stranded. The cost is a
3565+
// second encode pass over the split batch, which is already the exceptional
3566+
// large-batch path. encode is read-only on the table buffer, and simBaseline
3567+
// mirrors the publish loop's baseline advance (advanceSentMaxSymbolId), so
3568+
// each measured size equals the frame the publish loop will build; this pass
3569+
// mutates no delta/persist state (the defer-commit flag is a header bit that
3570+
// does not change frame size).
35563571
int nonEmptyCount = 0;
3572+
int simBaseline = symbolDeltaBaseline();
35573573
for (int i = 0, n = keys.size(); i < n; i++) {
35583574
CharSequence tableName = keys.getQuick(i);
35593575
if (tableName == null) {
35603576
continue;
35613577
}
35623578
QwpTableBuffer tableBuffer = tableBuffers.get(tableName);
3563-
if (tableBuffer != null && tableBuffer.getRowCount() > 0) {
3564-
nonEmptyCount++;
3579+
if (tableBuffer == null || tableBuffer.getRowCount() == 0) {
3580+
continue;
3581+
}
3582+
nonEmptyCount++;
3583+
encoder.beginMessage(1, globalSymbolDictionary, simBaseline, currentBatchMaxSymbolId);
3584+
encoder.addTable(tableBuffer);
3585+
int messageSize = encoder.finishMessage();
3586+
if (messageSize > serverMaxBatchSize) {
3587+
resetTableBuffersAfterFlush(keys);
3588+
throw new LineSenderException("single table batch too large for server batch cap")
3589+
.put(" [table=").put(tableName)
3590+
.put(", messageSize=").put(messageSize)
3591+
.put(", serverMaxBatchSize=").put(serverMaxBatchSize).put(']');
3592+
}
3593+
// Mirror advanceSentMaxSymbolId: once the first frame ships the batch's
3594+
// new ids, the remaining frames carry an empty delta above the baseline.
3595+
if (deltaDictEnabled && currentBatchMaxSymbolId > simBaseline) {
3596+
simBaseline = currentBatchMaxSymbolId;
35653597
}
35663598
}
35673599

@@ -3590,14 +3622,15 @@ private void flushPendingRowsSplit(ObjList<CharSequence> keys, boolean deferComm
35903622
encoder.addTable(tableBuffer);
35913623
int messageSize = encoder.finishMessage();
35923624
QwpBufferWriter buffer = encoder.getBuffer();
3593-
3594-
if (messageSize > serverMaxBatchSize) {
3595-
resetTableBuffersAfterFlush(keys);
3596-
throw new LineSenderException("single table batch too large for server batch cap")
3597-
.put(" [table=").put(tableName)
3598-
.put(", messageSize=").put(messageSize)
3599-
.put(", serverMaxBatchSize=").put(serverMaxBatchSize).put(']');
3600-
}
3625+
// The pre-flight pass above already verified every split frame fits the
3626+
// cap, so none can be found oversized here -- which is what keeps this
3627+
// loop from publishing (and stranding) a deferred prefix before an
3628+
// oversized table. The assert guards a future divergence between the two
3629+
// passes; it deliberately does NOT reset+throw here, because by this
3630+
// point a prefix may already be on the ring.
3631+
assert messageSize <= serverMaxBatchSize
3632+
: "split frame exceeded serverMaxBatchSize after pre-flight [table=" + tableName
3633+
+ ", messageSize=" + messageSize + ", serverMaxBatchSize=" + serverMaxBatchSize + ']';
36013634

36023635
// Write-ahead persist before publish (see flushPendingRows). The
36033636
// first split frame carries the batch's new symbols; the rest are

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

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,52 @@ public void testReconnectCatchUpRebuildsDictionary() throws Exception {
102102
});
103103
}
104104

105+
@Test
106+
public void testReconnectPreservesMonotonicDeltaBaseline() throws Exception {
107+
// Regression: the producer's sent-symbol watermark (sentMaxSymbolId) must
108+
// SURVIVE a reconnect. resetSymbolDictStateForNewConnection deliberately
109+
// leaves it untouched -- the I/O thread re-registers the whole dictionary via
110+
// a catch-up frame before replay, so the producer keeps shipping deltas ABOVE
111+
// the baseline across the wire boundary. If a regression reset it on
112+
// reconnect, the first post-reconnect data frame would re-ship the whole
113+
// dictionary inline (deltaStart=0), pure wasted bandwidth. The sibling
114+
// testReconnectCatchUpRebuildsDictionary asserts only that the final
115+
// dictionary is complete -- which a reset-then-redefine ALSO satisfies (the
116+
// server tolerates the redefinition) -- so it does NOT catch that regression.
117+
// This pins the baseline survival directly: connection 2's data frame must
118+
// carry a delta starting at id 1 (above alpha), not 0.
119+
assertMemoryLeak(() -> {
120+
CatchUpHandler handler = new CatchUpHandler();
121+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
122+
int port = server.getPort();
123+
server.start();
124+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
125+
126+
try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port + ";")) {
127+
// Connection 1 registers alpha (id 0); the server ACKs and drops it.
128+
sender.table("t").symbol("s", "alpha").longColumn("v", 1L).atNow();
129+
sender.flush();
130+
waitFor(() -> handler.dictFor(1).size() >= 1, 5_000);
131+
waitFor(() -> handler.conn1Closed, 5_000);
132+
133+
// Connection 2 (fresh) registers beta (id 1). With the baseline
134+
// preserved, beta ships as a delta ABOVE id 0 (deltaStart=1).
135+
sender.table("t").symbol("s", "beta").longColumn("v", 2L).atNow();
136+
sender.flush();
137+
waitFor(() -> handler.connectionsAccepted.get() >= 2
138+
&& handler.dictFor(2).size() >= 2, 5_000);
139+
}
140+
141+
Assert.assertTrue("connection 2 must re-register the dictionary via a catch-up first",
142+
handler.sawZeroTableFrameOnConn2);
143+
Assert.assertTrue("post-reconnect data frame must ship a delta ABOVE the surviving "
144+
+ "baseline (deltaStart >= 1); a reset baseline re-ships the whole "
145+
+ "dictionary from deltaStart 0",
146+
handler.conn2SawDeltaAboveBaseline);
147+
}
148+
});
149+
}
150+
105151
@Test
106152
public void testFixedCapNearBoundarySymbolCatchesUpWithoutTerminal() throws Exception {
107153
// Regression (homogeneous single cap): a symbol whose length sits just below
@@ -332,6 +378,13 @@ private static class CatchUpHandler implements TestWebSocketServer.WebSocketServ
332378
// Set from the flags byte of the zero-table catch-up frame on connection 2:
333379
// the catch-up carries no rows and must defer its (empty) commit.
334380
volatile boolean catchUpDeferredOnConn2;
381+
// Set when connection 2 receives a DATA frame (tableCount > 0) whose delta
382+
// starts ABOVE id 0 (deltaStart >= 1). This can only happen if the producer's
383+
// monotonic baseline SURVIVED the reconnect: a reset would re-ship the whole
384+
// dictionary from deltaStart 0. Robust to replay -- a replayed pre-reconnect
385+
// frame carries its original deltaStart 0, so only a genuinely-above-baseline
386+
// post-reconnect frame trips it.
387+
volatile boolean conn2SawDeltaAboveBaseline;
335388
volatile boolean sawZeroTableFrameOnConn2;
336389
private final List<List<String>> dictsByConn = new CopyOnWriteArrayList<>();
337390
private TestWebSocketServer.ClientHandler currentClient;
@@ -356,10 +409,24 @@ public synchronized void onBinaryMessage(TestWebSocketServer.ClientHandler clien
356409
int connNumber = dictsByConn.size();
357410
List<String> dict = dictsByConn.get(connNumber - 1);
358411
accumulate(data, dict);
359-
if (connNumber == 2 && tableCount(data) == 0) {
360-
sawZeroTableFrameOnConn2 = true;
361-
// FLAG_DEFER_COMMIT is bit 0x01 of the flags byte (offset 5).
362-
catchUpDeferredOnConn2 = (data[5] & 0x01) != 0;
412+
if (connNumber == 2) {
413+
if (tableCount(data) == 0) {
414+
sawZeroTableFrameOnConn2 = true;
415+
// FLAG_DEFER_COMMIT is bit 0x01 of the flags byte (offset 5).
416+
catchUpDeferredOnConn2 = (data[5] & 0x01) != 0;
417+
} else if (data.length >= 12 && (data[5] & 0x08) != 0) {
418+
// A post-reconnect DATA frame carrying a delta section
419+
// (FLAG_DELTA_SYMBOL_DICT = 0x08). A deltaStart >= 1 means the
420+
// producer resumed the delta ABOVE the surviving baseline; a reset
421+
// baseline would re-ship from deltaStart 0. Checking any frame (not
422+
// just the first) keeps this robust to a replayed pre-reconnect
423+
// frame arriving ahead of the new one -- that replay carries its
424+
// original deltaStart 0 and does not trip the flag.
425+
int[] pos = {12};
426+
if (readVarint(data, pos) >= 1) {
427+
conn2SawDeltaAboveBaseline = true;
428+
}
429+
}
363430
}
364431
try {
365432
client.sendBinary(buildAck(nextSeq.getAndIncrement()));

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
package io.questdb.client.test.cutlass.qwp.client;
2626

2727
import io.questdb.client.Sender;
28+
import io.questdb.client.cutlass.line.LineSenderException;
2829
import io.questdb.client.test.cutlass.qwp.websocket.TestWebSocketServer;
2930
import org.junit.Assert;
3031
import org.junit.Test;
@@ -273,6 +274,64 @@ public void testSplitBatchShipsDeltaOnFirstFrameOnly() throws Exception {
273274
});
274275
}
275276

277+
@Test
278+
public void testOversizedTableSplitStrandsNothing() throws Exception {
279+
// Regression: flushPendingRowsSplit publishes each table's frame one at a
280+
// time (all but the last deferred, i.e. appended but uncommitted). If a LATER
281+
// table's frame exceeds the cap, the split must not have already published an
282+
// earlier table's frame -- otherwise that prefix strands on the ring, a later
283+
// commit delivers it as a partial batch, and resetTableBuffersAfterFlush
284+
// discards every source row, all while flush() reported failure to the
285+
// caller. The pre-flight size pass makes the split all-or-nothing: an
286+
// oversized table throws BEFORE any frame is published. Pre-fix, the "small"
287+
// table's frame was published and committed on close, so the server saw it.
288+
assertMemoryLeak(() -> {
289+
CapturingHandler handler = new CapturingHandler();
290+
try (TestWebSocketServer server = new TestWebSocketServer(handler)) {
291+
server.setAdvertisedMaxBatchSize(200);
292+
int port = server.getPort();
293+
server.start();
294+
Assert.assertTrue(server.awaitStart(5, TimeUnit.SECONDS));
295+
296+
// auto_flush_bytes=off lets "big" accumulate PAST the cap (byte-based
297+
// auto-flush is otherwise clamped to 90% of the cap and would flush
298+
// first); the row/interval limits are set high so nothing auto-flushes
299+
// during the test. Each row stays under the per-row guard (< cap), but
300+
// 12 rows make "big"'s single frame exceed the cap, which no split can
301+
// shrink. "small" (added first) fits; "big" (added second) does not, so
302+
// the split hits it AFTER publishing "small" pre-fix.
303+
String pad = new String(new char[40]).replace('\0', 'x');
304+
try (Sender sender = Sender.fromConfig("ws::addr=localhost:" + port
305+
+ ";auto_flush_bytes=off;auto_flush_rows=1000000;auto_flush_interval=60000;")) {
306+
sender.table("small").symbol("s", "a").longColumn("v", 1L).atNow();
307+
for (int i = 0; i < 12; i++) {
308+
sender.table("big").stringColumn("p", pad).longColumn("v", (long) i).atNow();
309+
}
310+
try {
311+
sender.flush();
312+
Assert.fail("an oversized single-table split frame must throw");
313+
} catch (LineSenderException e) {
314+
Assert.assertTrue(e.getMessage(),
315+
e.getMessage().contains("too large for server batch cap"));
316+
}
317+
// close() drains the ring: pre-fix, the stranded "small" frame
318+
// would be sent (and committed) here.
319+
}
320+
321+
// No DATA frame (tableCount > 0) may have reached the server: the
322+
// oversized-table split published nothing. Pre-fix, "small" arrived.
323+
long dataFrames = 0;
324+
for (byte[] frame : handler.batches) {
325+
if (frame.length >= 8 && (((frame[6] & 0xFF) | ((frame[7] & 0xFF) << 8)) > 0)) {
326+
dataFrames++;
327+
}
328+
}
329+
Assert.assertEquals("an oversized-table split must publish NO data frame -- an "
330+
+ "earlier table's frame must not strand on the ring", 0, dataFrames);
331+
}
332+
});
333+
}
334+
276335
private static int readVarint(byte[] buf, int offset) {
277336
// Simple unsigned varint decode — sufficient for small values.
278337
int result = 0;

0 commit comments

Comments
 (0)