Skip to content

Commit 85dd6e8

Browse files
committed
test(qwp): red-first -- transport window between gap sweeps must not burn the drainer's settle wall clock
The capability-gap episode deadline anchors at gap error #1 and keeps ticking through an intervening transport window (cluster unreachable). A window longer than reconnect_max_duration_millis makes gap error #2 observe an expired deadline and quarantine the slot after just 2 gap sweeps -- contradicting the 16-attempt settle intent and the documented contract that transients never consume the budget. Sibling test testTransportErrorDoesNotResetCapabilityGapEpisode pins the attempt counter across the window but cannot see the wall-clock burn (its transport blip is instantaneous).
1 parent 5b51236 commit 85dd6e8

1 file changed

Lines changed: 52 additions & 0 deletions

File tree

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,58 @@ public void testTransportErrorDoesNotResetCapabilityGapEpisode() {
604604
assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
605605
}
606606

607+
@Test
608+
public void testTransportWindowDoesNotBurnCapabilityGapWallClock() {
609+
// Red-first: the wall-clock half of the settle budget is anchored at
610+
// gap #1, and a transport window BETWEEN gap sweeps must PAUSE it --
611+
// only gap-to-gap time is the cluster "failing to settle". Under the
612+
// bug the deadline keeps ticking while the cluster is unreachable:
613+
// gap #1 anchors the deadline, the cluster then drops off the network
614+
// for longer than the entire budget (transport errors are retried
615+
// "forever" and charge nothing else), and when it comes back still
616+
// briefly gapped, gap #2 observes an expired deadline and quarantines
617+
// the slot after just 2 gap sweeps -- contradicting both the
618+
// 16-attempt settle intent and Invariant B's "transients never
619+
// consume the budget". Evasion is not a concern: the attempt counter
620+
// survives the window untouched, which
621+
// testTransportErrorDoesNotResetCapabilityGapEpisode pins.
622+
// Here the cluster actually settles after the outage (two more gap
623+
// sweeps, then durable-ack-capable), so the drain must proceed --
624+
// no escalation, no sentinel.
625+
long budgetMillis = 250L;
626+
CountingListener listener = new CountingListener();
627+
AtomicInteger sweeps = new AtomicInteger();
628+
ScriptedFactory factory = ScriptedFactory.failingTimes(4, () -> {
629+
if (sweeps.incrementAndGet() == 2) {
630+
// Cluster fully unreachable for ~2.5x the wall-clock budget.
631+
// A real outage is time spent inside reconnect() walking
632+
// unreachable endpoints, so model it inside the factory.
633+
try {
634+
Thread.sleep(budgetMillis * 2 + 100);
635+
} catch (InterruptedException ignored) {
636+
Thread.currentThread().interrupt();
637+
}
638+
return new LineSenderException("Failed to connect: all 2 endpoint(s) "
639+
+ "unreachable; last=127.0.0.1:9000");
640+
}
641+
return new QwpDurableAckMismatchException("h", 1234, "primary");
642+
});
643+
BackgroundDrainer drainer = newDrainerWithBudgets(
644+
factory, budgetMillis, FAST_BACKOFF_MILLIS, FAST_BACKOFF_MAX_MILLIS);
645+
drainer.setListener(listener);
646+
WebSocketClient out = drainer.connectWithDurableAckRetry();
647+
assertSame("cluster recovered after the outage -- the drain must proceed, not "
648+
+ "quarantine on a wall clock burned by the transport window",
649+
factory.successSentinel(), out);
650+
// gap #1 + outage + gap #2 + gap #3 + success = 5 sweeps.
651+
assertEquals(5, factory.attempts());
652+
assertEquals(BackgroundDrainer.DrainOutcome.PENDING, drainer.outcome());
653+
assertEquals("transport window must not trigger persistent-failure escalation",
654+
0, listener.persistentFailures.get());
655+
assertFalse("no .failed sentinel: the slot was never in a terminal state",
656+
Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
657+
}
658+
607659
private BackgroundDrainer newDrainer(ScriptedFactory factory) {
608660
return newDrainerWithBudgets(
609661
factory, FAST_RECONNECT_MAX_DURATION_MILLIS,

0 commit comments

Comments
 (0)