@@ -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