Skip to content

Commit 84293fa

Browse files
committed
Reset cap-gap budget across unrelated outages
1 parent 8d6a932 commit 84293fa

2 files changed

Lines changed: 170 additions & 31 deletions

File tree

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

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)