Skip to content

Commit 031777e

Browse files
glasstigerclaude
andcommitted
Cover the cap-gap budget reset on success
The cap-gap settle budget (catchUpCapGapAttempts) counts consecutive catch-up cap gaps across reconnects and resets to 0 on a successful catch-up, so gaps interspersed with successes -- a rolling-cap cluster -- never accumulate to a spurious terminal. The existing latch test only accrues gaps under one fixed cap with no success interleaved, so a dropped reset would have gone unnoticed. Add testSuccessfulCatchUpResetsCapGapBudget: accrue max-1 cap gaps, let a larger-cap node accept the dictionary, then assert the budget is back to 0 and that max-1 further gaps still latch no terminal. Make the stub client's advertised cap mutable so the test can model the rolling cap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8facb8d commit 031777e

1 file changed

Lines changed: 60 additions & 1 deletion

File tree

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

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,63 @@ public void testCatchUpCapGapRetriesUntilBudgetThenLatches() throws Exception {
321321
});
322322
}
323323

324+
@Test
325+
public void testSuccessfulCatchUpResetsCapGapBudget() throws Exception {
326+
// The cap-gap settle budget (catchUpCapGapAttempts) counts CONSECUTIVE cap
327+
// gaps across reconnects; a successful catch-up ends the episode and MUST reset
328+
// it to 0 (sendDictCatchUp's final line). Otherwise cap gaps interspersed with
329+
// successful catch-ups -- a rolling-cap cluster where a larger-cap node comes
330+
// and goes -- would accumulate to a spurious terminal over a long-lived sender.
331+
// testCatchUpCapGapRetriesUntilBudgetThenLatches only accrues gaps under one
332+
// fixed cap with no success interleaved, so it cannot pin the reset.
333+
TestUtils.assertMemoryLeak(() -> {
334+
Field maxField = CursorWebSocketSendLoop.class.getDeclaredField("MAX_CATCHUP_CAP_GAP_ATTEMPTS");
335+
maxField.setAccessible(true);
336+
int maxAttempts = maxField.getInt(null);
337+
CatchUpCapturingClient client = new CatchUpCapturingClient(160); // too small for a 200-char symbol
338+
try (CursorSendEngine engine = newEngine()) {
339+
CursorWebSocketSendLoop loop = newLoop(engine, client);
340+
try {
341+
seedMirror(loop, TestUtils.repeat("x", 200));
342+
// Accrue max-1 consecutive cap gaps (each retriable, no terminal).
343+
for (int i = 1; i < maxAttempts; i++) {
344+
try {
345+
invokeSetWireBaselineWithCatchUp(loop, engine.ackedFsn() + 1L);
346+
fail("cap gap must raise a retriable CatchUpSendException (attempt " + i + ')');
347+
} catch (InvocationTargetException e) {
348+
assertEquals("CatchUpSendException", e.getCause().getClass().getSimpleName());
349+
}
350+
}
351+
assertEquals("precondition: budget accrued to max-1",
352+
maxAttempts - 1, readInt(loop, "catchUpCapGapAttempts"));
353+
354+
// A larger-cap node returns: the whole dictionary re-registers with
355+
// no cap gap, so the settle budget must reset to 0.
356+
client.cap = 0; // no cap => the 200-char symbol fits one frame
357+
invokeSetWireBaselineWithCatchUp(loop, engine.ackedFsn() + 1L);
358+
assertEquals("a successful catch-up must reset the cap-gap settle budget",
359+
0, readInt(loop, "catchUpCapGapAttempts"));
360+
361+
// Behavioural proof the budget is genuinely fresh: max-1 more cap
362+
// gaps still latch NO terminal (they would if the counter had stayed
363+
// at max-1 -- one more gap would have hit the cap and killed the sender).
364+
client.cap = 160;
365+
for (int i = 1; i < maxAttempts; i++) {
366+
try {
367+
invokeSetWireBaselineWithCatchUp(loop, engine.ackedFsn() + 1L);
368+
fail("post-reset cap gap must be retriable (attempt " + i + ')');
369+
} catch (InvocationTargetException e) {
370+
assertEquals("CatchUpSendException", e.getCause().getClass().getSimpleName());
371+
}
372+
loop.checkError(); // fresh budget => still under max => no terminal
373+
}
374+
} finally {
375+
loop.close();
376+
}
377+
}
378+
});
379+
}
380+
324381
@Test
325382
public void testMirrorOverflowFailsLoud() throws Exception {
326383
// ensureSentDictCapacity must latch a terminal -- not silently overflow the
@@ -539,7 +596,9 @@ private static long writeVarint(long addr, long value) {
539596
// when throwOnSend is set -- raises a transient wire error to model the fresh
540597
// connection dropping mid-catch-up.
541598
private static final class CatchUpCapturingClient extends WebSocketClient {
542-
private final int cap;
599+
// Mutable so a test can model a rolling-cap cluster: raise it for a node that
600+
// accepts the dictionary, lower it for a smaller-cap node that cap-gaps.
601+
private int cap;
543602
private final boolean throwOnSend;
544603
private int framesSent;
545604

0 commit comments

Comments
 (0)