Skip to content

Commit f9ece1a

Browse files
committed
fix(qwp): orphan drainer retries a down server instead of quarantining (Invariant B)
connectWithDurableAckRetry() routed every 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 quarantined an orphan slot's un-drained SF data on a transient outage, needing manual recovery, and was asymmetric with the live sender's background loop (CursorWebSocketSendLoop.connectLoop, which retries transport errors forever). It was worst exactly under lazy_connect=true (server down at startup is the whole point), where it quarantined all orphan data on boot. Make the drainer classify failures exactly like connectLoop: - NEW catch (QwpAuthFailedException | WebSocketUpgradeException) -> terminal, quarantine immediately (genuinely non-retriable across the cluster). - catch (Throwable) -> TRANSIENT: back off (capped exponential) and retry indefinitely until a primary is reachable, stopRequested, or SF exhaustion; never quarantine. WARN throttled to 1/5s so a long/permanent outage stays observable without flooding. - Role reject (all-replica) keeps retry-forever; durable-ack capability gap keeps its bounded settle budget (the one deliberate rolling-upgrade tolerance). Tests: - testTransportErrorNeverQuarantinesInvariantB: now green (down server retried). - testNonDurableAckExceptionMarksFailedImmediately -> testTerminalUpgrade MarksFailedImmediately: repurposed to a genuine upgrade terminal, since a transport error is now retryable. 49 drainer/pool tests pass.
1 parent a8fea31 commit f9ece1a

2 files changed

Lines changed: 39 additions & 11 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/BackgroundDrainer.java

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
package io.questdb.client.cutlass.qwp.client.sf.cursor;
2626

2727
import io.questdb.client.cutlass.http.client.WebSocketClient;
28+
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
29+
import io.questdb.client.cutlass.qwp.client.QwpAuthFailedException;
2830
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
2931
import io.questdb.client.cutlass.qwp.client.QwpIngressRoleRejectedException;
3032
import io.questdb.client.cutlass.qwp.client.QwpRoleMismatchException;
@@ -172,6 +174,7 @@ public WebSocketClient connectWithDurableAckRetry() {
172174
// window can last minutes and (Invariant B) is retried indefinitely, so
173175
// per-attempt logging would flood. Mirrors CursorWebSocketSendLoop.
174176
long lastReplicaWarnNanos = 0L;
177+
long lastTransportWarnNanos = 0L;
175178
while (!stopRequested) {
176179
// True only for a genuine durable-ack CAPABILITY gap, which is
177180
// bounded by the settle budget / attempt cap. A transient all-replica
@@ -181,6 +184,17 @@ public WebSocketClient connectWithDurableAckRetry() {
181184
boolean boundedByBudget = false;
182185
try {
183186
return clientFactory.reconnect();
187+
} catch (QwpAuthFailedException | WebSocketUpgradeException e) {
188+
// Genuinely non-retriable across the cluster (auth 401/403, or a
189+
// non-421 upgrade reject): waiting will not fix it, so quarantine
190+
// immediately -- exactly as the live sender's background loop
191+
// (CursorWebSocketSendLoop.connectLoop) halts on these errors.
192+
String msg = e.getMessage();
193+
LOG.error("drainer terminal upgrade/auth error for slot {}: {}", slotPath, msg);
194+
lastErrorMessage = msg;
195+
OrphanScanner.markFailed(slotPath, "auth/upgrade: " + msg);
196+
outcome = DrainOutcome.FAILED;
197+
return null;
184198
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
185199
// INVARIANT B: every reachable endpoint is a REPLICA right now.
186200
// A replica is promotable and a primary will reappear, so this is
@@ -248,12 +262,21 @@ public WebSocketClient connectWithDurableAckRetry() {
248262
LOG.warn("drainer slot {} attempt {}: durable-ack unavailable, retrying after backoff",
249263
slotPath, mismatchAttempts);
250264
} catch (Throwable t) {
251-
String msg = t.getMessage();
252-
LOG.error("drainer initial connect failed for slot {}: {}", slotPath, msg);
253-
lastErrorMessage = msg;
254-
OrphanScanner.markFailed(slotPath, "initial connect: " + msg);
255-
outcome = DrainOutcome.FAILED;
256-
return null;
265+
// INVARIANT B: a transport failure -- the whole cluster is
266+
// unreachable right now (server down, network partition) -- is
267+
// TRANSIENT, exactly as the live sender's background loop treats
268+
// it. The server will come back; keep retrying (capped backoff)
269+
// until it does, stopRequested, or SF exhaustion. NEVER quarantine
270+
// the slot on a transport error. Genuine terminals (auth /
271+
// non-421 upgrade / durable-ack capability gap) are handled by the
272+
// catches above and still fail fast.
273+
lastErrorMessage = t.getMessage();
274+
long nowWarn = System.nanoTime();
275+
if (nowWarn - lastTransportWarnNanos >= 5_000_000_000L) {
276+
LOG.warn("drainer slot {}: cluster unreachable ({}), retrying after backoff",
277+
slotPath, t.getMessage());
278+
lastTransportWarnNanos = nowWarn;
279+
}
257280
}
258281
// Backoff before the next sweep. Honor stopRequested by parking in
259282
// small chunks rather than a single long park so close() doesn't

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
import io.questdb.client.DefaultHttpClientConfiguration;
2828
import io.questdb.client.cutlass.http.client.WebSocketClient;
29+
import io.questdb.client.cutlass.http.client.WebSocketUpgradeException;
2930
import io.questdb.client.network.PlainSocketFactory;
3031
import io.questdb.client.cutlass.line.LineSenderException;
3132
import io.questdb.client.cutlass.qwp.client.QwpDurableAckMismatchException;
@@ -40,7 +41,6 @@
4041
import org.junit.Before;
4142
import org.junit.Test;
4243

43-
import java.io.IOException;
4444
import java.nio.file.Paths;
4545
import java.util.ArrayList;
4646
import java.util.List;
@@ -211,10 +211,15 @@ public void testNoListenerNoNullPointerOnEscalation() {
211211
}
212212

213213
@Test
214-
public void testNonDurableAckExceptionMarksFailedImmediately() {
214+
public void testTerminalUpgradeMarksFailedImmediately() {
215215
CountingListener listener = new CountingListener();
216+
// A genuinely non-retriable upgrade error (non-421 5xx upgrade reject) is
217+
// terminal -- waiting will not fix it -- so the drainer quarantines on the
218+
// first attempt, exactly like the live sender's background loop halts on
219+
// auth/upgrade. A TRANSPORT error, by contrast, is transient and is
220+
// retried (see testTransportErrorNeverQuarantinesInvariantB).
216221
ScriptedFactory factory = ScriptedFactory.alwaysFailing(
217-
() -> new IOException("transport down"));
222+
() -> new WebSocketUpgradeException(500, null, "server error during upgrade"));
218223
BackgroundDrainer drainer = newDrainer(factory);
219224
drainer.setListener(listener);
220225
WebSocketClient out = drainer.connectWithDurableAckRetry();
@@ -223,10 +228,10 @@ public void testNonDurableAckExceptionMarksFailedImmediately() {
223228
// Listener must not have been touched — this path doesn't fire either callback.
224229
assertEquals(0, listener.unavailableAttempts.size());
225230
assertEquals(0, listener.persistentFailures.get());
226-
// Sentinel reason should reflect the non-DA path (initial connect: ...).
231+
// Sentinel dropped for a genuine terminal.
227232
String sentinel = slotPath + "/" + OrphanScanner.FAILED_SENTINEL_NAME;
228233
assertTrue(Files.exists(sentinel));
229-
// The factory must have been invoked exactly once — no retry on this path.
234+
// The factory must have been invoked exactly once — no retry on a terminal.
230235
assertEquals(1, factory.attempts());
231236
}
232237

0 commit comments

Comments
 (0)