Skip to content

Commit d0a794a

Browse files
committed
fix(qwp): orphan drainer must not quarantine an all-replica window (Invariant B)
BackgroundDrainer.connectWithDurableAckRetry() lumped role rejects (QwpRoleMismatchException / QwpIngressRoleRejectedException -- an all-endpoints-replica failover window) together with a genuine durable-ack capability gap and gave up on BOTH the 16-attempt cap and the reconnect_max_duration_millis wall clock -> markFailed / .failed sentinel / DrainOutcome.FAILED. A transient failover window thus quarantined an orphan slot's un-drained SF data instead of waiting for a promotion. Split the catch: - QwpRoleMismatchException | QwpIngressRoleRejectedException (all endpoints are replicas right now) -> TRANSIENT. Fire the per-attempt observability callback and retry with capped exponential backoff indefinitely; never escalate, never quarantine. Only stopRequested (or a primary reappearing, or SF exhaustion) ends it. WARN throttled to 1/5s. - QwpDurableAckMismatchException (a server upgraded but does not advertise durable ack -- a real cluster-wide capability gap) -> stays terminal with the bounded settle budget + attempt cap + .failed sentinel, as before. Backoff now clamps to the remaining budget ONLY on the bounded capability-gap path (so it still escalates promptly at the deadline); the transient failover path backs off capped-exponential and never busy-loops past a deadline. Turns green testAllReplicaWindowNeverEscalatesInvariantB while keeping testEscalatesAfterMaxAttemptsAndDropsSentinel and testWallTimeBudgetEscalatesBeforeAttemptCap valid. 48 drainer/pool tests pass.
1 parent 0d65c3a commit d0a794a

1 file changed

Lines changed: 51 additions & 12 deletions

File tree

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

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -162,18 +162,50 @@ public WebSocketClient connectWithDurableAckRetry() {
162162
long deadlineNanos = startNanos + reconnectMaxDurationMillis * 1_000_000L;
163163
long backoffMillis = reconnectInitialBackoffMillis;
164164
int mismatchAttempts = 0;
165+
// Throttle the all-replica retry WARN to one per 5s: a real failover
166+
// window can last minutes and (Invariant B) is retried indefinitely, so
167+
// per-attempt logging would flood. Mirrors CursorWebSocketSendLoop.
168+
long lastReplicaWarnNanos = 0L;
165169
while (!stopRequested) {
170+
// True only for a genuine durable-ack CAPABILITY gap, which is
171+
// bounded by the settle budget / attempt cap. A transient all-replica
172+
// failover window (role reject) is retried indefinitely under
173+
// Invariant B and leaves this false, so its backoff is never clamped
174+
// to the deadline (which would otherwise busy-loop once past it).
175+
boolean boundedByBudget = false;
166176
try {
167177
return clientFactory.reconnect();
168-
} catch (QwpDurableAckMismatchException | QwpRoleMismatchException
169-
| QwpIngressRoleRejectedException e) {
170-
// An all-replica window (every endpoint role-rejected the
171-
// upgrade) is handled exactly like a durable-ack-unavailable
172-
// cluster: give it a budget to settle, then quarantine. A
173-
// replica can be promoted, so the drainer must not quarantine an
174-
// orphaned slot on the first sweep just because no primary is
175-
// reachable yet. Behaviour is unchanged from when buildAndConnect
176-
// surfaced this same condition as QwpDurableAckMismatchException.
178+
} catch (QwpRoleMismatchException | QwpIngressRoleRejectedException e) {
179+
// INVARIANT B: every reachable endpoint is a REPLICA right now.
180+
// A replica is promotable and a primary will reappear, so this is
181+
// a TRANSIENT failover window, NOT a capability gap. The drainer
182+
// must keep retrying (capped backoff) until a primary is reachable,
183+
// stopRequested, or SF exhaustion -- it must NEVER quarantine the
184+
// slot on a wall-clock budget or an attempt cap. Surface the
185+
// per-attempt observability callback, then back off and retry.
186+
mismatchAttempts++;
187+
BackgroundDrainerListener l = listener;
188+
if (l != null) {
189+
try {
190+
l.onDurableAckUnavailable(slotPath, mismatchAttempts);
191+
} catch (Throwable cb) {
192+
LOG.warn("drainer listener onDurableAckUnavailable threw: {}",
193+
cb.getMessage());
194+
}
195+
}
196+
long nowWarn = System.nanoTime();
197+
if (nowWarn - lastReplicaWarnNanos >= 5_000_000_000L) {
198+
LOG.warn("drainer slot {} attempt {}: all endpoints are replicas "
199+
+ "(transient failover window), retrying after backoff",
200+
slotPath, mismatchAttempts);
201+
lastReplicaWarnNanos = nowWarn;
202+
}
203+
} catch (QwpDurableAckMismatchException e) {
204+
// Genuine cluster-wide durable-ack CAPABILITY gap: a server
205+
// upgraded but does not advertise durable ack. Unlike a role
206+
// reject this will not clear by waiting for a promotion, so it
207+
// stays terminal for the drainer -- give the cluster a bounded
208+
// settle budget (rolling upgrade), then quarantine the slot.
177209
mismatchAttempts++;
178210
long now = System.nanoTime();
179211
long elapsedMs = (now - startNanos) / 1_000_000L;
@@ -198,6 +230,7 @@ public WebSocketClient connectWithDurableAckRetry() {
198230
outcome = DrainOutcome.FAILED;
199231
return null;
200232
}
233+
boundedByBudget = true;
201234
if (l != null) {
202235
try {
203236
l.onDurableAckUnavailable(slotPath, mismatchAttempts);
@@ -218,10 +251,16 @@ public WebSocketClient connectWithDurableAckRetry() {
218251
}
219252
// Backoff before the next sweep. Honor stopRequested by parking in
220253
// small chunks rather than a single long park so close() doesn't
221-
// wait for a full sleep to elapse.
254+
// wait for a full sleep to elapse. Only the bounded (capability-gap)
255+
// path clamps to the remaining budget so it escalates promptly at the
256+
// deadline; the transient failover path retries indefinitely and just
257+
// backs off (capped exponential), never busy-looping past a deadline.
222258
long jitter = ThreadLocalRandom.current().nextLong(Math.max(1L, backoffMillis));
223-
long sleepMillis = Math.min(backoffMillis + jitter,
224-
Math.max(0L, (deadlineNanos - System.nanoTime()) / 1_000_000L));
259+
long sleepMillis = backoffMillis + jitter;
260+
if (boundedByBudget) {
261+
sleepMillis = Math.min(sleepMillis,
262+
Math.max(0L, (deadlineNanos - System.nanoTime()) / 1_000_000L));
263+
}
225264
if (sleepMillis > 0L && !stopRequested) {
226265
LockSupport.parkNanos(sleepMillis * 1_000_000L);
227266
}

0 commit comments

Comments
 (0)