Skip to content

Commit 0d65c3a

Browse files
committed
test(qwp): red-first guard - orphan drainer must not quarantine an all-replica window (Invariant B)
BackgroundDrainer.connectWithDurableAckRetry() lumps role rejects (QwpRoleMismatchException / QwpIngressRoleRejectedException -- an all-endpoints-replica failover window) in with a genuine durable-ack capability gap, and gives up on BOTH the 16-attempt cap and the reconnect_max_duration_millis wall clock -> markFailed / .failed sentinel / DrainOutcome.FAILED. Under Invariant B an all-replica window is TRANSIENT (a replica gets promoted, a primary reappears): the drainer must keep retrying with capped backoff until a primary is reachable, stopRequested, or SF exhaustion. It must never quarantine the slot on a wall-clock budget or attempt cap. Add testAllReplicaWindowNeverEscalatesInvariantB: runs the drainer against a factory that always role-rejects, observes past both give-up triggers, and asserts it is still retrying (PENDING, no sentinel, no persistent-failure callback, attempts past the cap). Red now (attempts=16, outcome=FAILED); green once the drainer splits the catch (role reject -> retry forever; capability gap -> stays terminal, keeping testEscalatesAfterMaxAttempts* valid).
1 parent 6be20d6 commit 0d65c3a

1 file changed

Lines changed: 70 additions & 0 deletions

File tree

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import io.questdb.client.cutlass.http.client.WebSocketClient;
2929
import io.questdb.client.network.PlainSocketFactory;
3030
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
31+
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
3132
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
3233
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener;
3334
import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop;
@@ -327,6 +328,75 @@ public void testWallTimeBudgetEscalatesBeforeAttemptCap() {
327328
assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
328329
}
329330

331+
@Test
332+
public void testAllReplicaWindowNeverEscalatesInvariantB() throws Exception {
333+
// INVARIANT B (orphan drainer): a store-and-forward drainer must NEVER
334+
// quarantine a slot just because every reachable endpoint is a REPLICA.
335+
// A replica is promotable and a primary will reappear, so an all-replica
336+
// window is a TRANSIENT failover state -- the drainer must keep retrying
337+
// (capped backoff) until a primary is reachable, stopRequested, or SF
338+
// exhaustion. NEITHER the 16-attempt cap NOR the wall-clock reconnect
339+
// budget may escalate it to a .failed sentinel.
340+
//
341+
// Distinct from testEscalatesAfterMaxAttemptsAndDropsSentinel /
342+
// testWallTimeBudgetEscalatesBeforeAttemptCap, which use a genuine
343+
// durable-ack CAPABILITY gap (QwpDurableAckMismatchException -- a server
344+
// upgrades but does not advertise durable ack): that is a real config
345+
// problem and stays terminal. This test uses a role reject (every
346+
// endpoint is a replica right now), which must NOT be terminal.
347+
//
348+
// Red-first: connectWithDurableAckRetry() currently lumps role rejects in
349+
// with the durable-ack-mismatch give-up, so after the 16-attempt cap /
350+
// the budget it markFailed()s and returns -> the helper thread dies. Goes
351+
// green once the drainer treats an all-replica window as retry-forever
352+
// (split the catch: role reject -> retry; capability gap -> quarantine).
353+
CountingListener listener = new CountingListener();
354+
AtomicInteger attempts = new AtomicInteger();
355+
ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
356+
attempts.incrementAndGet();
357+
return new QwpIngressRoleRejectedException(
358+
QwpIngressRoleRejectedException.ROLE_REPLICA, "127.0.0.1", 9000);
359+
});
360+
// SHORT budget + tiny backoff so BOTH give-up triggers (the 16-attempt
361+
// cap and the 200ms wall clock) would fire promptly under the bug.
362+
BackgroundDrainer drainer = newDrainerWithBudgets(
363+
factory, /*reconnectMaxDurationMillis*/ 200L, /*backoffInit*/ 1L, /*backoffMax*/ 2L);
364+
drainer.setListener(listener);
365+
Thread t = new Thread(drainer::connectWithDurableAckRetry, "invariant-b-orphan-drainer");
366+
t.setDaemon(true);
367+
t.start();
368+
369+
// Observe well past BOTH the 200ms budget and the 16-attempt cap. Under
370+
// the bug the drainer escalates (within the cap time) and the helper
371+
// thread dies; a contract-honoring drainer is still retrying here.
372+
long observeUntilNanos = System.nanoTime() + 600_000_000L; // 600ms >> 200ms budget
373+
while (System.nanoTime() < observeUntilNanos && t.isAlive()) {
374+
Thread.sleep(10);
375+
}
376+
377+
try {
378+
assertTrue("orphan drainer gave up on a transient all-replica window (attempts="
379+
+ attempts.get() + ", outcome=" + drainer.outcome() + "): Invariant B "
380+
+ "forbids quarantining a slot on the 16-attempt cap or the wall-clock "
381+
+ "reconnect budget -- a replica is promotable, so the drainer must keep "
382+
+ "retrying until a primary reappears or SF is exhausted",
383+
t.isAlive());
384+
assertEquals("must not escalate a transient all-replica window to FAILED",
385+
BackgroundDrainer.DrainOutcome.PENDING, drainer.outcome());
386+
assertEquals("must not fire persistent-failure on an all-replica window",
387+
0, listener.persistentFailures.get());
388+
assertFalse("must not quarantine (.failed sentinel) an all-replica window",
389+
Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
390+
assertTrue("must have retried past the 16-attempt cap (got " + attempts.get() + ")",
391+
attempts.get() > BackgroundDrainer.DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS);
392+
} finally {
393+
drainer.requestStop();
394+
t.join(5_000);
395+
}
396+
assertFalse("helper must exit after stop", t.isAlive());
397+
assertEquals(BackgroundDrainer.DrainOutcome.STOPPED, drainer.outcome());
398+
}
399+
330400
private BackgroundDrainer newDrainer(ScriptedFactory factory) {
331401
return newDrainerWithBudgets(
332402
factory, FAST_RECONNECT_MAX_DURATION_MILLIS,

0 commit comments

Comments
 (0)