Skip to content

Commit a8fea31

Browse files
committed
test(qwp): red-first - orphan drainer must not quarantine a down server (Invariant B)
BackgroundDrainer.connectWithDurableAckRetry() routes any non-role, non-durable -ack Throwable -- including "all endpoints unreachable" (server down / network partition) -- to an IMMEDIATE markFailed / .failed sentinel on the FIRST sweep. That quarantines an orphan slot's un-drained SF data on a transient outage, requiring manual operator recovery, and is asymmetric with the live sender's background loop (CursorWebSocketSendLoop.connectLoop), which backs off and retries a transport error indefinitely. Invariant B: a down/unreachable cluster is transient (the server will come back). The orphan drainer must behave exactly like the live background loop -- retry with capped backoff until a primary is reachable, stopRequested, or SF exhaustion; only genuine terminals (auth / non-421 upgrade / durable-ack capability gap) fail fast. Add testTransportErrorNeverQuarantinesInvariantB: alwaysFailing factory throwing LineSenderException("all endpoints unreachable"), observed past a short budget; asserts still retrying (PENDING, no sentinel, attempts past the first sweep). Red now (attempts=1, outcome=FAILED); green once transport errors are retried like connectLoop.
1 parent fe0148a commit a8fea31

1 file changed

Lines changed: 61 additions & 0 deletions

File tree

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import io.questdb.client.DefaultHttpClientConfiguration;
2828
import io.questdb.client.cutlass.http.client.WebSocketClient;
2929
import io.questdb.client.network.PlainSocketFactory;
30+
import io.questdb.client.cutlass.line.LineSenderException;
3031
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
3132
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
3233
import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer;
@@ -397,6 +398,66 @@ public void testAllReplicaWindowNeverEscalatesInvariantB() throws Exception {
397398
assertEquals(BackgroundDrainer.DrainOutcome.STOPPED, drainer.outcome());
398399
}
399400

401+
@Test
402+
public void testTransportErrorNeverQuarantinesInvariantB() throws Exception {
403+
// INVARIANT B (orphan drainer): a fully-unreachable cluster (server down,
404+
// network partition -- every endpoint refuses / times out) is TRANSIENT,
405+
// not terminal. The server will come back; the drainer must keep retrying
406+
// (capped backoff) until it does, stopRequested, or SF exhaustion -- it
407+
// must NEVER quarantine the slot on the first failed sweep. This is the
408+
// exact behaviour of the live sender's background loop
409+
// (CursorWebSocketSendLoop.connectLoop: a transport error backs off and
410+
// retries), which the orphan drainer must match.
411+
//
412+
// Red-first: connectWithDurableAckRetry() currently routes any non-role,
413+
// non-durable-ack Throwable (including "all endpoints unreachable") to an
414+
// IMMEDIATE markFailed / .failed sentinel on the first attempt. Green once
415+
// transport errors are retried indefinitely like connectLoop. (Genuine
416+
// terminals -- auth / non-421 upgrade -- must still fail fast.)
417+
CountingListener listener = new CountingListener();
418+
AtomicInteger attempts = new AtomicInteger();
419+
ScriptedFactory factory = ScriptedFactory.alwaysFailing(() -> {
420+
attempts.incrementAndGet();
421+
return new LineSenderException(
422+
"Failed to connect: all 2 endpoint(s) unreachable; last=127.0.0.1:9000");
423+
});
424+
BackgroundDrainer drainer = newDrainerWithBudgets(
425+
factory, /*reconnectMaxDurationMillis*/ 200L, /*backoffInit*/ 1L, /*backoffMax*/ 2L);
426+
drainer.setListener(listener);
427+
Thread t = new Thread(drainer::connectWithDurableAckRetry, "invariant-b-transport-drainer");
428+
t.setDaemon(true);
429+
t.start();
430+
431+
// Observe well past the 200ms budget: the drainer must still be retrying.
432+
long observeUntilNanos = System.nanoTime() + 600_000_000L; // 600ms >> 200ms budget
433+
while (System.nanoTime() < observeUntilNanos && t.isAlive()) {
434+
Thread.sleep(10);
435+
}
436+
437+
try {
438+
assertTrue("orphan drainer quarantined a fully-unreachable (server-down) cluster "
439+
+ "(attempts=" + attempts.get() + ", outcome=" + drainer.outcome()
440+
+ "): Invariant B says a down server is transient -- the drainer must "
441+
+ "retry indefinitely (exactly like the live background loop), never "
442+
+ "quarantine on a transport error",
443+
t.isAlive());
444+
assertEquals("must not escalate a transient transport error to FAILED",
445+
BackgroundDrainer.DrainOutcome.PENDING, drainer.outcome());
446+
assertEquals("transport retry must not fire a persistent-failure escalation",
447+
0, listener.persistentFailures.get());
448+
assertFalse("must not quarantine (.failed sentinel) a down server",
449+
Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME));
450+
assertTrue("must have retried the down server well past the first sweep (got "
451+
+ attempts.get() + ")",
452+
attempts.get() > BackgroundDrainer.DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS);
453+
} finally {
454+
drainer.requestStop();
455+
t.join(5_000);
456+
}
457+
assertFalse("helper must exit after stop", t.isAlive());
458+
assertEquals(BackgroundDrainer.DrainOutcome.STOPPED, drainer.outcome());
459+
}
460+
400461
private BackgroundDrainer newDrainer(ScriptedFactory factory) {
401462
return newDrainerWithBudgets(
402463
factory, FAST_RECONNECT_MAX_DURATION_MILLIS,

0 commit comments

Comments
 (0)