|
28 | 28 | import io.questdb.client.cutlass.http.client.WebSocketClient; |
29 | 29 | import io.questdb.client.network.PlainSocketFactory; |
30 | 30 | import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException; |
| 31 | +import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException; |
31 | 32 | import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainer; |
32 | 33 | import io.questdb.client.cutlass.qwp.client.sf.cursor.BackgroundDrainerListener; |
33 | 34 | import io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop; |
@@ -327,6 +328,75 @@ public void testWallTimeBudgetEscalatesBeforeAttemptCap() { |
327 | 328 | assertTrue(Files.exists(slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME)); |
328 | 329 | } |
329 | 330 |
|
| 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 | + |
330 | 400 | private BackgroundDrainer newDrainer(ScriptedFactory factory) { |
331 | 401 | return newDrainerWithBudgets( |
332 | 402 | factory, FAST_RECONNECT_MAX_DURATION_MILLIS, |
|
0 commit comments