@@ -163,8 +163,8 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
163163 private static final Logger LOG = LoggerFactory .getLogger (CursorWebSocketSendLoop .class );
164164 // Settle budget for the symbol-dict catch-up cap gap: how many cap-gap attempts
165165 // -- catch-ups that reached a fresh server and found a single dictionary entry
166- // too large for its advertised batch cap -- may occur, with no intervening
167- // SUCCESSFUL catch-up, before an orphan drainer latches a terminal. This is a
166+ // too large for its advertised batch cap -- may occur consecutively before an
167+ // orphan drainer latches a terminal. This is a
168168 // SANCTIONED orphan-only terminal (a genuine cluster batch-size capability gap),
169169 // the connect-time analog of the orphan drainer's durable-ack capability gap
170170 // (DEFAULT_MAX_DURABLE_ACK_MISMATCH_ATTEMPTS). A homogeneous cluster never trips
@@ -173,18 +173,17 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
173173 // cluster, where a failover to a smaller-cap node can hit it for an entry an
174174 // earlier node accepted. A foreground sender retries forever. An orphan drainer
175175 // rides out the transient window until a larger-cap node returns; only a persistent
176- // gap (this many cap gaps with no successful catch-up in between) latches.
176+ // gap (this many consecutive cap gaps with no successful catch-up or unrelated
177+ // reconnect state in between) latches.
177178 //
178179 // Budget accounting (satisfies "a transient must never burn the terminal
179180 // budget"): catchUpCapGapAttempts increments ONLY inside sendDictCatchUp when a
180- // node is reached and an entry is oversized, and resets ONLY when a catch-up fully
181- // succeeds. A TRANSIENT reconnect (connect refuse, upgrade/role failure) never
182- // reaches the catch-up, so it NEITHER increments nor resets the counter -- it only
183- // lengthens the wall-clock settle window. The terminal therefore always requires
184- // this many GENUINE cap gaps; a transient can never inflate it. Deliberately NOT
185- // reset on a mere successful RECONNECT: a reconnect to the small-cap node itself
186- // produces the cap gap, so resetting there would stop a persistent gap from ever
187- // latching -- the reset must gate on a successful CATCH-UP, not on connecting.
181+ // node is reached and an entry is oversized. A successful catch-up ends the
182+ // episode, as does any unrelated reconnect state (connect refusal, catch-up send
183+ // failure, upgrade/role rejection): otherwise its downtime would count toward the
184+ // wall-clock dwell even though no cap gap was observed. The cap-gap exception
185+ // itself does NOT reset the episode, so consecutive small-cap nodes still prove a
186+ // persistent cluster capability gap and can exhaust the orphan policy.
188187 private static final int MAX_CATCHUP_CAP_GAP_ATTEMPTS = 16 ;
189188 // Hard ceiling for the lifetime-monotonic sent-dictionary mirror. The mirror
190189 // fields are int, so it cannot exceed Integer.MAX_VALUE bytes; reaching even
@@ -302,19 +301,17 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
302301 private int sentDictBytesLen ;
303302 private int sentDictCount ;
304303 // Orphan-policy cap-gap attempts -- catch-ups that reached a node and found an entry
305- // too large for its batch cap -- since the last SUCCESSFUL catch-up (see
306- // MAX_CATCHUP_CAP_GAP_ATTEMPTS for the full budget accounting). Foreground retries
307- // never increment it. A successful catch-up resets it; a transient reconnect neither
308- // increments nor resets it. I/O-thread-only.
304+ // too large for its batch cap -- with no intervening successful catch-up or unrelated
305+ // reconnect state (see MAX_CATCHUP_CAP_GAP_ATTEMPTS for the full budget accounting).
306+ // Foreground retries never increment it. I/O-thread-only.
309307 private int catchUpCapGapAttempts ;
310308 // System.nanoTime() of the FIRST cap gap of the current orphan-policy episode.
311309 // Anchors the escalation dwell. Reset together with catchUpCapGapAttempts on a
312- // successful catch-up, so a node that accepts the dictionary ends the episode.
313- // Anchoring at the FIRST gap (not at loop entry, and not refreshed per attempt) is
314- // what keeps a TRANSIENT from burning the terminal budget: a transient never reaches
315- // the catch-up, so it neither increments the counter nor moves the anchor -- it can
316- // only lengthen the wall clock, which alone can never latch the terminal because the
317- // strike count still has to be satisfied. I/O-thread-only, like catchUpCapGapAttempts.
310+ // successful catch-up or an unrelated reconnect state, so only an uninterrupted run
311+ // of cap-gap observations contributes wall-clock dwell. Anchoring at the FIRST gap
312+ // (not refreshed per attempt) lets consecutive small-cap nodes satisfy the dwell;
313+ // resetting after transport/role states prevents their downtime from doing so.
314+ // I/O-thread-only, like catchUpCapGapAttempts.
318315 //
319316 // -1 marks "no episode open" for a debugger or a heap dump, but it is NOT the test
320317 // for one -- catchUpCapGapAttempts == 0 is (see sendDictCatchUp). A nanoTime instant
@@ -1436,6 +1433,15 @@ private void clearDurableAckTracking() {
14361433 lastFrameOrPingNanos = 0L ;
14371434 }
14381435
1436+ private static boolean isCatchUpCapGap (Throwable t ) {
1437+ return t instanceof CatchUpSendException && ((CatchUpSendException ) t ).capGap ;
1438+ }
1439+
1440+ private void resetCatchUpCapGapEpisode () {
1441+ catchUpCapGapAttempts = 0 ;
1442+ catchUpCapGapFirstNanos = -1L ;
1443+ }
1444+
14391445 /**
14401446 * Shared per-outage retry loop. Used by {@link #fail(Throwable)} for
14411447 * mid-flight wire failures (phase="reconnect") and by
@@ -1450,6 +1456,13 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
14501456 recordFatal (initial );
14511457 return ;
14521458 }
1459+ // A wire/role state that interrupted an orphan cap-gap run is not evidence that
1460+ // the cluster's batch cap stayed incompatible during the outage. Start a fresh
1461+ // episode; the cap-gap exception itself is the one reconnect cause that preserves
1462+ // the existing attempt count and dwell anchor.
1463+ if (!isCatchUpCapGap (initial )) {
1464+ resetCatchUpCapGapEpisode ();
1465+ }
14531466 LOG .warn ("cursor I/O loop entering {} loop: {}" ,
14541467 phase , initial .getMessage ());
14551468 long outageStartNanos = System .nanoTime ();
@@ -1519,6 +1532,9 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
15191532 phase , elapsedMs , attempts , fsnAtZero );
15201533 return ;
15211534 }
1535+ // A null factory result is an unsuccessful connect state, not a cap-gap
1536+ // observation. Do not let the time spent retrying it satisfy orphan dwell.
1537+ resetCatchUpCapGapEpisode ();
15221538 } catch (QwpAuthFailedException | WebSocketUpgradeException e ) {
15231539 // Terminal across all configured endpoints per spec sf-client.md
15241540 // section 13.3: auth (401/403) bypasses reconnect and surfaces as
@@ -1595,6 +1611,7 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
15951611 // endpoint, forever. Growing to reconnectMaxBackoffMillis
15961612 // mirrors the orphan drainer's role-reject path and honours the
15971613 // documented capped-exponential-backoff contract.
1614+ resetCatchUpCapGapEpisode ();
15981615 lastReconnectError = e ;
15991616 long now = System .nanoTime ();
16001617 if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS ) {
@@ -1618,6 +1635,9 @@ private void connectLoop(Throwable initial, String phase, long paceFirstAttemptM
16181635 recordFatal (e );
16191636 throw (Error ) e ;
16201637 }
1638+ if (!isCatchUpCapGap (e )) {
1639+ resetCatchUpCapGapEpisode ();
1640+ }
16211641 lastReconnectError = e ;
16221642 long now = System .nanoTime ();
16231643 if (now - lastLogNanos >= RECONNECT_LOG_THROTTLE_NANOS ) {
@@ -2490,10 +2510,10 @@ private int sendDictCatchUp() {
24902510 // it to the producer. Only an orphan drainer may apply the settle budget
24912511 // below and quarantine its slot after a persistent gap. Under budget the
24922512 // throw is RETRIABLE (no recordFatal) -- connectLoop reconnects with
2493- // backoff and re-runs the catch-up, which resets the counter on a node
2494- // that accepts it. A transient reconnect (connect /upgrade failure, role
2495- // reject) never reaches the catch-up , so it neither increments nor burns
2496- // the orphan budget. On exhaustion latch via recordFatal, NOT fail() --
2513+ // backoff and re-runs the catch-up, which resets the episode on a node
2514+ // that accepts it. An unrelated transport /upgrade/role state also resets
2515+ // the episode , so its downtime cannot satisfy the orphan dwell. On
2516+ // exhaustion latch via recordFatal, NOT fail() --
24972517 // failing from inside the catch-up would re-enter connectLoop (see
24982518 // CatchUpSendException); the data must be resent after the cap is raised.
24992519 // Escalation needs BOTH the strike count AND a minimum wall-clock dwell
@@ -2517,7 +2537,7 @@ private int sendDictCatchUp() {
25172537 throw new CatchUpSendException (new LineSenderException (
25182538 "symbol dictionary entry too large for the server batch cap during catch-up ["
25192539 + "frameLen=" + soloFrameLen + ", cap=" + cap + ']'
2520- + "; retrying indefinitely -- a larger-cap node may return" ));
2540+ + "; retrying indefinitely -- a larger-cap node may return" ), true );
25212541 }
25222542
25232543 long nowNanos = System .nanoTime ();
@@ -2540,7 +2560,7 @@ private int sendDictCatchUp() {
25402560 if (exhausted ) {
25412561 recordFatal (err );
25422562 }
2543- throw new CatchUpSendException (err );
2563+ throw new CatchUpSendException (err , true );
25442564 }
25452565 if (chunkSymbols > 0 && chunkBytes + entryBytes > budget ) {
25462566 sendCatchUpChunk (chunkStartId , chunkSymbols , chunkStartAddr , (int ) chunkBytes );
@@ -2563,8 +2583,7 @@ private int sendDictCatchUp() {
25632583 // count and the wall-clock anchor. Resetting only the count would leave a stale
25642584 // anchor from an old episode, so the very first strike of a LATER episode would
25652585 // already satisfy the dwell.
2566- catchUpCapGapAttempts = 0 ;
2567- catchUpCapGapFirstNanos = -1L ;
2586+ resetCatchUpCapGapEpisode ();
25682587 return framesSent ;
25692588 }
25702589
@@ -2888,11 +2907,20 @@ public interface ReconnectFactory {
28882907 * connectLoop}'s own retry catch (swapClient path), a fresh {@code fail()}
28892908 * from the I/O loop body (trySendOne path), or dropping the dead client so
28902909 * the I/O thread reconnects (start path). A JVM {@code Error} is never
2891- * wrapped -- it must stay terminal.
2910+ * wrapped -- it must stay terminal. The {@code capGap} marker lets the reconnect
2911+ * loop preserve only consecutive incompatible-cap observations; ordinary catch-up
2912+ * send failures restart the orphan settle episode.
28922913 */
28932914 private static final class CatchUpSendException extends RuntimeException {
2915+ private final boolean capGap ;
2916+
28942917 CatchUpSendException (Throwable cause ) {
2918+ this (cause , false );
2919+ }
2920+
2921+ CatchUpSendException (Throwable cause , boolean capGap ) {
28952922 super (cause );
2923+ this .capGap = capGap ;
28962924 }
28972925 }
28982926
0 commit comments