Skip to content

Commit 0f9857e

Browse files
committed
fix(qwp): pause the drainer's capability-gap wall clock across transport windows
The settle budget's wall-clock half was an absolute deadline anchored at the first gap error, so a mid-episode transport window (cluster fully unreachable) kept burning it: an outage longer than reconnect_max_duration_millis made the next gap sweep observe an expired deadline and quarantine the slot after as few as 2 gap attempts, contradicting the documented contract that transients never consume the budget. The episode wall clock now ACCUMULATES only across uninterrupted gap-to-gap intervals: a transport error invalidates the interval it interrupts (the clock pauses) while preserving the attempt counter and the elapsed time already accumulated -- so a flaky-but-misconfigured cluster still cannot evade the 16-attempt cap (pinned by testTransportErrorDoesNotResetCapabilityGapEpisode). A role reject still restarts the episode entirely (topology churn). The backoff clamp now tracks remaining accumulated budget instead of the absolute deadline, preserving prompt escalation once genuine gap-time runs out.
1 parent 85dd6e8 commit 0f9857e

1 file changed

Lines changed: 54 additions & 30 deletions

File tree

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

Lines changed: 54 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,12 @@ public final class BackgroundDrainer implements Runnable {
7474
* wall-clock budget {@code reconnectMaxDurationMillis} also caps this
7575
* capability-gap loop; whichever is hit first triggers escalation. Both
7676
* halves of the budget measure a capability-gap <i>episode</i>: the
77-
* deadline is anchored at the first capability-gap error (never at
78-
* connect entry), and an intervening role reject restarts the episode
79-
* -- it proves the topology changed, so the next capability-gap error
80-
* is a fresh episode against a newly promoted node. 16
77+
* wall clock accumulates only across uninterrupted gap-to-gap intervals
78+
* (never before the first gap is observed, and never across an
79+
* intervening transport window -- an unreachable cluster is not
80+
* "failing to settle"), and an intervening role reject restarts the
81+
* episode -- it proves the topology changed, so the next capability-gap
82+
* error is a fresh episode against a newly promoted node. 16
8183
* attempts gives the cluster room to settle through a rolling upgrade
8284
* (each attempt walks every endpoint internally) without letting a genuine
8385
* cluster-wide misconfig hang the drainer forever.
@@ -183,8 +185,11 @@ public BackgroundDrainer() {
183185
* {@link QwpDurableAckMismatchException} sweeps only. Transient
184186
* conditions -- an all-replica failover window (role reject) or a
185187
* transport error -- are retried indefinitely (Invariant B) and never
186-
* consume the budget; a role reject additionally restarts the episode,
187-
* because it proves the topology changed under the rolling upgrade.
188+
* consume the budget: the wall-clock half accumulates only across
189+
* uninterrupted gap-to-gap intervals, so a mid-episode transport window
190+
* pauses the clock (without touching the attempt count), and a role
191+
* reject additionally restarts the episode, because it proves the
192+
* topology changed under the rolling upgrade.
188193
* Genuine terminals (auth failure, non-421 upgrade reject) preserve
189194
* the original behavior: mark failed, exit.
190195
*
@@ -199,17 +204,28 @@ public WebSocketClient connectWithDurableAckRetry() {
199204
runnerThread = Thread.currentThread();
200205
long backoffMillis = reconnectInitialBackoffMillis;
201206
// Capability-gap settle budget. Counts ONLY consecutive
202-
// QwpDurableAckMismatchException sweeps; the wall-clock deadline is
203-
// anchored lazily at the FIRST capability-gap error of an episode so
207+
// QwpDurableAckMismatchException sweeps; the wall-clock half
208+
// accumulates ONLY across uninterrupted gap-to-gap intervals, so
204209
// transient churn (role reject, transport) can never burn the budget
205-
// before a genuine gap is even observed. An intervening role reject
206-
// resets the episode (topology churn: the offending node is gone); a
207-
// transport error neither increments nor resets -- a dropped socket
208-
// does not prove promotion churn, and resetting on it would let a
209-
// flaky-but-misconfigured cluster evade the cap forever.
210+
// -- neither before the first gap is observed nor mid-episode (a
211+
// cluster unreachable for longer than the whole budget that comes
212+
// back still gapped has consumed none of it). An intervening role
213+
// reject resets the episode (topology churn: the offending node is
214+
// gone); a transport error neither increments nor resets the attempt
215+
// count -- a dropped socket does not prove promotion churn, and
216+
// resetting on it would let a flaky-but-misconfigured cluster evade
217+
// the cap forever -- it only pauses the wall clock: the gap-to-gap
218+
// interval spanning the transport window is not charged.
210219
int capabilityGapAttempts = 0;
211-
long capabilityGapStartNanos = 0L; // 0 = no episode active
212-
long capabilityGapDeadlineNanos = Long.MAX_VALUE;
220+
// Wall-clock time accumulated across uninterrupted gap-to-gap
221+
// intervals of the current episode; escalates once it reaches
222+
// capabilityGapBudgetNanos (or the attempt cap fires first).
223+
long capabilityGapElapsedNanos = 0L;
224+
// Timestamp of the previous capability-gap sweep; 0 = the next gap
225+
// charges nothing (episode start, post-role-reject restart, or the
226+
// interval was interrupted by a transport window).
227+
long lastCapabilityGapNanos = 0L;
228+
final long capabilityGapBudgetNanos = reconnectMaxDurationMillis * 1_000_000L;
213229
// Observability-only counter for the transient all-replica window;
214230
// never consulted for escalation (Invariant B).
215231
int roleRejectAttempts = 0;
@@ -249,11 +265,11 @@ public WebSocketClient connectWithDurableAckRetry() {
249265
roleRejectAttempts++;
250266
// Topology is mid-churn: whatever node produced any earlier
251267
// capability-gap errors is no longer the primary the next
252-
// sweep hits, so the gap episode (attempts + deadline)
268+
// sweep hits, so the gap episode (attempts + wall clock)
253269
// restarts and the next gap gets the full settle budget.
254270
capabilityGapAttempts = 0;
255-
capabilityGapStartNanos = 0L;
256-
capabilityGapDeadlineNanos = Long.MAX_VALUE;
271+
capabilityGapElapsedNanos = 0L;
272+
lastCapabilityGapNanos = 0L;
257273
BackgroundDrainerListener l = listener;
258274
if (l != null) {
259275
try {
@@ -278,16 +294,17 @@ public WebSocketClient connectWithDurableAckRetry() {
278294
// settle budget (rolling upgrade), then quarantine the slot.
279295
capabilityGapAttempts++;
280296
long now = System.nanoTime();
281-
if (capabilityGapStartNanos == 0L) {
282-
// First gap error of this episode: anchor the wall-clock
283-
// half of the budget HERE, not at connect entry, so time
284-
// spent in a preceding transient window is not charged.
285-
capabilityGapStartNanos = now;
286-
capabilityGapDeadlineNanos = now + reconnectMaxDurationMillis * 1_000_000L;
297+
if (lastCapabilityGapNanos != 0L) {
298+
// Charge only the interval since the PREVIOUS gap sweep,
299+
// and only when no transient error interrupted it. Time
300+
// spent in a transient window -- before the first gap or
301+
// between two gaps -- is never charged to the episode.
302+
capabilityGapElapsedNanos += now - lastCapabilityGapNanos;
287303
}
288-
long elapsedMs = (now - capabilityGapStartNanos) / 1_000_000L;
304+
lastCapabilityGapNanos = now;
305+
long elapsedMs = capabilityGapElapsedNanos / 1_000_000L;
289306
boolean exhausted = capabilityGapAttempts >= DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS
290-
|| now >= capabilityGapDeadlineNanos;
307+
|| capabilityGapElapsedNanos >= capabilityGapBudgetNanos;
291308
BackgroundDrainerListener l = listener;
292309
if (exhausted) {
293310
LOG.error("drainer giving up on slot {} after {} durable-ack-mismatch attempts ({}ms): {}",
@@ -328,6 +345,11 @@ public WebSocketClient connectWithDurableAckRetry() {
328345
// non-421 upgrade / durable-ack capability gap) are handled by the
329346
// catches above and still fail fast.
330347
lastErrorMessage = t.getMessage();
348+
// Pause the episode wall clock: the gap-to-gap interval this
349+
// window interrupts is never charged. Attempts and elapsed
350+
// already accumulated are preserved (anti-evasion: see the
351+
// budget comment above).
352+
lastCapabilityGapNanos = 0L;
331353
long nowWarn = System.nanoTime();
332354
if (nowWarn - lastTransportWarnNanos >= 5_000_000_000L) {
333355
LOG.warn("drainer slot {}: cluster unreachable ({}), retrying after backoff",
@@ -338,14 +360,16 @@ public WebSocketClient connectWithDurableAckRetry() {
338360
// Backoff before the next sweep. Honor stopRequested by parking in
339361
// small chunks rather than a single long park so close() doesn't
340362
// wait for a full sleep to elapse. Only the bounded (capability-gap)
341-
// path clamps to the remaining budget so it escalates promptly at the
342-
// deadline; the transient failover path retries indefinitely and just
343-
// backs off (capped exponential), never busy-looping past a deadline.
363+
// path clamps to the remaining budget (the post-gap sleep is charged
364+
// to the episode by the next gap sweep) so it escalates promptly once
365+
// the accumulated gap-time runs out; the transient failover path
366+
// retries indefinitely and just backs off (capped exponential),
367+
// never busy-looping past an exhausted budget.
344368
long jitter = ThreadLocalRandom.current().nextLong(Math.max(1L, backoffMillis));
345369
long sleepMillis = backoffMillis + jitter;
346370
if (boundedByBudget) {
347371
sleepMillis = Math.min(sleepMillis,
348-
Math.max(0L, (capabilityGapDeadlineNanos - System.nanoTime()) / 1_000_000L));
372+
Math.max(0L, (capabilityGapBudgetNanos - capabilityGapElapsedNanos) / 1_000_000L));
349373
}
350374
if (sleepMillis > 0L && !stopRequested) {
351375
long parkDeadlineNanos = System.nanoTime() + sleepMillis * 1_000_000L;

0 commit comments

Comments
 (0)