Skip to content

Commit cba62ac

Browse files
committed
fix(qwp): pace transient close recycles + minimum poison-escalation dwell
The poison-frame detector could turn a TRANSIENT, post-connect outage into a producer-fatal PROTOCOL_VIOLATION -- the exact false-positive its design contract forbids ("a genuine outage fails at connect, not deterministically on one FSN"). A middlebox/LB that completes the WS upgrade, accepts the head frame, then non-orderly-closes (1006/1011) while its backend is briefly down succeeds at CONNECT every cycle, so connectLoop's failed-connect backoff never engages. Two gaps let that latch a terminal: 1. Below the strike threshold, onClose recycled through the UNPACED fail() -- unlike the NACK path, which uses failPaced(). Strikes then accrued at connect+send+close RTT rate (well under a second) with no widening gaps. Fix: route the below-threshold non-orderly-close strike through the same strike-escalated failPaced the NACK path uses; genuine transport closes (orderly, or before any send) still reconnect immediately via fail(). 2. Escalation fired on strike count alone, so even with pacing a brief outage (longer than the ~sub-second strike window) still escalated. Fix: gate escalation on a minimum wall-clock dwell -- poisonStrikes >= threshold AND the suspect has stayed poisoned for at least poison_min_escalation_window_ millis (default 5000ms). This decouples "how many strikes prove determinism" from "how long a transient is allowed to look poisoned"; an OK at/beyond the suspect during the window resets the detector. 0 = legacy immediate escalation at the threshold. The dwell is first-class config, mirroring max_frame_rejections end-to-end: connect-string key poison_min_escalation_window_millis (ConfigSchema INGRESS), LineSenderBuilder.poisonMinEscalationWindowMillis(long), forwarded to the cursor send loop and every BackgroundDrainer. The raw CursorWebSocketSendLoop constructor overloads default the dwell to 0 (legacy) so existing tests keep exact escalate-at-N semantics; the 5s user default is applied at the config layer, same split max_frame_rejections uses. Tests (CursorWebSocketSendLoopPoisonFrameTest): - testNonOrderlyCloseRecycleIsPacedAgainstAcceptingMiddlebox: real I/O thread; unpaced code records ~200k recycles/1.2s, paced <=10. Proven red->green. - testPoisonDwellHoldsEscalationUntilWallClockWindowElapses: strikes past the threshold must not escalate until the window elapses. Proven red->green. - ServerErrorAckTerminalTest escalation tests set poison_min_escalation_window_millis=0 so exact per-strike counts stay assertable; WsSenderConfigHonoredTest pins the new key end-to-end.
1 parent c10a582 commit cba62ac

8 files changed

Lines changed: 352 additions & 19 deletions

File tree

core/src/main/java/io/questdb/client/Sender.java

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1050,6 +1050,7 @@ final class LineSenderBuilder {
10501050
// PARAMETER_NOT_SET_EXPLICITLY → spec default (256).
10511051
private int errorInboxCapacity = PARAMETER_NOT_SET_EXPLICITLY;
10521052
private int maxFrameRejections = PARAMETER_NOT_SET_EXPLICITLY;
1053+
private long poisonMinEscalationWindowMillis = PARAMETER_NOT_SET_EXPLICITLY;
10531054
private String httpPath;
10541055
private String httpSettingsPath;
10551056
private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY;
@@ -1501,6 +1502,9 @@ public Sender build() {
15011502
int actualMaxFrameRejections = maxFrameRejections != PARAMETER_NOT_SET_EXPLICITLY
15021503
? maxFrameRejections
15031504
: CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS;
1505+
long actualPoisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis != PARAMETER_NOT_SET_EXPLICITLY
1506+
? poisonMinEscalationWindowMillis
1507+
: CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS;
15041508

15051509
// sfDir is the parent (group root); the actual slot lives
15061510
// under sfDir/senderId. This is what the engine sees — the
@@ -1573,7 +1577,8 @@ public Sender build() {
15731577
connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis,
15741578
connectionListener,
15751579
actualConnectionListenerInboxCapacity,
1576-
actualMaxFrameRejections
1580+
actualMaxFrameRejections,
1581+
actualPoisonMinEscalationWindowMillis
15771582
);
15781583
} catch (Throwable t) {
15791584
// connect() failed before ownership of cursorEngine
@@ -2443,6 +2448,30 @@ public LineSenderBuilder maxFrameRejections(int rejections) {
24432448
return this;
24442449
}
24452450

2451+
/**
2452+
* Minimum wall-clock dwell (millis) a suspected frame must stay
2453+
* poisoned before the poison detector escalates to a typed terminal,
2454+
* even once {@link #maxFrameRejections(int)} strikes have accrued.
2455+
* With paced recycles, strikes can accrue in well under a second (e.g.
2456+
* a middlebox/LB that accepts the connection then non-orderly-closes
2457+
* each cycle while its backend is briefly down), so a strike count
2458+
* alone can turn a transient outage into a producer-fatal terminal.
2459+
* This window guarantees a brief outage a chance to clear (an OK
2460+
* at/beyond the suspect resets the detector) first. {@code 0} disables
2461+
* the dwell (legacy immediate escalation at the strike threshold).
2462+
* Default {@code 5_000} (5 s). WebSocket only.
2463+
*/
2464+
public LineSenderBuilder poisonMinEscalationWindowMillis(long millis) {
2465+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
2466+
throw new LineSenderException("poison_min_escalation_window_millis is only supported for WebSocket transport");
2467+
}
2468+
if (millis < 0) {
2469+
throw new LineSenderException("poison_min_escalation_window_millis must be >= 0: ").put(millis);
2470+
}
2471+
this.poisonMinEscalationWindowMillis = millis;
2472+
return this;
2473+
}
2474+
24462475
/**
24472476
* Max reconnect backoff in millis. Caps the exponential growth so
24482477
* a long outage doesn't end up sleeping minutes between attempts.
@@ -3378,6 +3407,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
33783407
}
33793408
pos = getValue(configurationString, pos, sink, "max_frame_rejections");
33803409
maxFrameRejections(parseIntValue(sink, "max_frame_rejections"));
3410+
} else if (Chars.equals("poison_min_escalation_window_millis", sink)) {
3411+
if (protocol != PROTOCOL_WEBSOCKET) {
3412+
throw new LineSenderException("poison_min_escalation_window_millis is only supported for WebSocket transport");
3413+
}
3414+
pos = getValue(configurationString, pos, sink, "poison_min_escalation_window_millis");
3415+
poisonMinEscalationWindowMillis(parseLongValue(sink, "poison_min_escalation_window_millis"));
33813416
} else if (Chars.equals("initial_connect_retry", sink)) {
33823417
if (protocol != PROTOCOL_WEBSOCKET) {
33833418
throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport");
@@ -3646,6 +3681,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
36463681
if (view.has("max_frame_rejections")) {
36473682
maxFrameRejections(wsInt(view, v, "max_frame_rejections"));
36483683
}
3684+
if (view.has("poison_min_escalation_window_millis")) {
3685+
poisonMinEscalationWindowMillis(wsLong(view, v, "poison_min_escalation_window_millis"));
3686+
}
36493687
if (view.has("sf_append_deadline_millis")) {
36503688
sfAppendDeadlineMillis(wsLong(view, v, "sf_append_deadline_millis"));
36513689
}
@@ -3821,6 +3859,7 @@ public java.util.Map<String, Object> wsConfigSnapshotForTest() {
38213859
m.put("drain_orphans", drainOrphans);
38223860
m.put("max_background_drainers", maxBackgroundDrainers);
38233861
m.put("max_frame_rejections", maxFrameRejections);
3862+
m.put("poison_min_escalation_window_millis", poisonMinEscalationWindowMillis);
38243863
m.put("error_inbox_capacity", errorInboxCapacity);
38253864
m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity);
38263865
m.put("token", httpToken);

core/src/main/java/io/questdb/client/cutlass/qwp/client/QwpWebSocketSender.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,10 @@ public class QwpWebSocketSender implements Sender {
284284
// Poison-frame detector threshold forwarded to the cursor send loop and to
285285
// every background drainer (connect-string key max_frame_rejections).
286286
private int maxFrameRejections = CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS;
287+
// Minimum wall-clock dwell before poison escalation, forwarded alongside
288+
// maxFrameRejections (connect-string key poison_min_escalation_window_millis).
289+
private long poisonMinEscalationWindowMillis =
290+
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS;
287291
private long reconnectInitialBackoffMillis =
288292
CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS;
289293
private long reconnectMaxBackoffMillis =
@@ -655,7 +659,8 @@ public static QwpWebSocketSender connect(
655659
initialConnectMode, errorHandler, errorInboxCapacity,
656660
durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs,
657661
connectionListener, connectionListenerInboxCapacity,
658-
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
662+
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
663+
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS);
659664
}
660665

661666
/**
@@ -685,7 +690,8 @@ public static QwpWebSocketSender connect(
685690
int connectTimeoutMs,
686691
SenderConnectionListener connectionListener,
687692
int connectionListenerInboxCapacity,
688-
int maxFrameRejections
693+
int maxFrameRejections,
694+
long poisonMinEscalationWindowMillis
689695
) {
690696
QwpWebSocketSender sender = new QwpWebSocketSender(
691697
endpoints, tlsConfig,
@@ -702,6 +708,7 @@ public static QwpWebSocketSender connect(
702708
sender.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
703709
sender.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
704710
sender.maxFrameRejections = maxFrameRejections;
711+
sender.poisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis;
705712
sender.initialConnectMode = initialConnectMode == null
706713
? Sender.InitialConnectMode.OFF
707714
: initialConnectMode;
@@ -2353,7 +2360,8 @@ public synchronized void startOrphanDrainers(
23532360
reconnectMaxBackoffMillis,
23542361
requestDurableAck,
23552362
durableAckKeepaliveIntervalMillis,
2356-
maxFrameRejections);
2363+
maxFrameRejections,
2364+
poisonMinEscalationWindowMillis);
23572365
ref[0] = drainer;
23582366
drainerPool.submit(drainer);
23592367
}
@@ -3211,7 +3219,8 @@ private void ensureConnected() {
32113219
reconnectMaxBackoffMillis,
32123220
requestDurableAck,
32133221
durableAckKeepaliveIntervalMillis,
3214-
maxFrameRejections);
3222+
maxFrameRejections,
3223+
poisonMinEscalationWindowMillis);
32153224
// Plug the async-delivery sink before start() so the I/O thread
32163225
// never observes a null dispatcher between recordFatal and
32173226
// notification — the test for null in dispatchError handles

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ public final class BackgroundDrainer implements Runnable {
129129
// Poison-frame detector threshold forwarded to every drain loop this
130130
// drainer creates; mirrors the owner sender's max_frame_rejections config.
131131
private final int maxHeadFrameRejections;
132+
// Minimum wall-clock dwell before poison escalation, forwarded to every
133+
// drain loop; mirrors the owner sender's poison_min_escalation_window_millis.
134+
private final long poisonMinEscalationWindowMillis;
132135

133136
public BackgroundDrainer(
134137
String slotPath,
@@ -145,7 +148,8 @@ public BackgroundDrainer(
145148
reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
146149
reconnectMaxBackoffMillis, requestDurableAck,
147150
durableAckKeepaliveIntervalMillis,
148-
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
151+
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS,
152+
CursorWebSocketSendLoop.DEFAULT_POISON_MIN_ESCALATION_WINDOW_MILLIS);
149153
}
150154

151155
/**
@@ -164,7 +168,8 @@ public BackgroundDrainer(
164168
long reconnectMaxBackoffMillis,
165169
boolean requestDurableAck,
166170
long durableAckKeepaliveIntervalMillis,
167-
int maxHeadFrameRejections
171+
int maxHeadFrameRejections,
172+
long poisonMinEscalationWindowMillis
168173
) {
169174
this.slotPath = slotPath;
170175
this.segmentSizeBytes = segmentSizeBytes;
@@ -176,6 +181,7 @@ public BackgroundDrainer(
176181
this.requestDurableAck = requestDurableAck;
177182
this.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
178183
this.maxHeadFrameRejections = maxHeadFrameRejections;
184+
this.poisonMinEscalationWindowMillis = poisonMinEscalationWindowMillis;
179185
}
180186

181187
/**
@@ -187,7 +193,8 @@ public BackgroundDrainer(
187193
*/
188194
@TestOnly
189195
public BackgroundDrainer() {
190-
this(null, 0L, 0L, null, 0L, 0L, 0L, false, 0L);
196+
this(null, 0L, 0L, null, 0L, 0L, 0L, false, 0L,
197+
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS, 0L);
191198
}
192199

193200
/**
@@ -567,7 +574,8 @@ public void run() {
567574
reconnectMaxBackoffMillis,
568575
requestDurableAck,
569576
durableAckKeepaliveIntervalMillis,
570-
maxHeadFrameRejections);
577+
maxHeadFrameRejections,
578+
poisonMinEscalationWindowMillis);
571579
loop.start();
572580

573581
while (!stopRequestedOrInterrupted()) {

0 commit comments

Comments
 (0)