Skip to content

Commit 4278296

Browse files
committed
feat(qwp): make the poison-frame threshold configurable (max_frame_rejections)
Turns CursorWebSocketSendLoop.MAX_HEAD_FRAME_REJECTIONS (renamed DEFAULT_MAX_HEAD_FRAME_REJECTIONS = 4) into per-sender configuration: consecutive server-active rejections (retriable NACK or non-orderly close) of the same head-of-line frame, with no ack progress in between, before the sender declares the frame poisoned and latches a typed terminal instead of reconnect-replaying forever. Configurable from every surface: - connect string: max_frame_rejections=N (>= 1), both the hand parser and the ConfigView path; registered as an INGRESS key in ConfigSchema, so the QwpQueryClient accepts it as a syntactic no-op (one vocabulary, side-owned application -- pinned by QwpConfigKeysTest for both clients). - programmatic: LineSenderBuilder.maxFrameRejections(int), WebSocket only, validated >= 1. - QuestDB facade: the config string flows through the pool to every built Sender; an out-of-range value is rejected at build() even with min=0 pools (QuestDBBuilderTest). The threshold is forwarded to the cursor send loop AND to every BackgroundDrainer (the drainer replays the owner's SF data, so it must honor the same threshold). All existing constructors are preserved as delegating overloads with the default. Tests: WsSenderConfigHonoredTest drift guard (key applied, not merely accepted); ServerErrorAckTerminalTest e2e proves max_frame_rejections=2 escalates after exactly two deliveries of the same frame instead of the default four.
1 parent 8302335 commit 4278296

11 files changed

Lines changed: 243 additions & 26 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ core/CMakeCache.txt
3535

3636
# pi subagents runtime artifacts
3737
.pi-subagents/
38+
core/src/main/resources/io/questdb/client/bin/

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,7 @@ final class LineSenderBuilder {
10491049
// Bounded inbox capacity for the async error dispatcher.
10501050
// PARAMETER_NOT_SET_EXPLICITLY → spec default (256).
10511051
private int errorInboxCapacity = PARAMETER_NOT_SET_EXPLICITLY;
1052+
private int maxFrameRejections = PARAMETER_NOT_SET_EXPLICITLY;
10521053
private String httpPath;
10531054
private String httpSettingsPath;
10541055
private int httpTimeout = PARAMETER_NOT_SET_EXPLICITLY;
@@ -1497,6 +1498,9 @@ public Sender build() {
14971498
durableAckKeepaliveIntervalMillis == DURABLE_ACK_KEEPALIVE_NOT_SET
14981499
? CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS
14991500
: durableAckKeepaliveIntervalMillis;
1501+
int actualMaxFrameRejections = maxFrameRejections != PARAMETER_NOT_SET_EXPLICITLY
1502+
? maxFrameRejections
1503+
: CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS;
15001504

15011505
// sfDir is the parent (group root); the actual slot lives
15021506
// under sfDir/senderId. This is what the engine sees — the
@@ -1568,7 +1572,8 @@ public Sender build() {
15681572
authTimeoutMillis,
15691573
connectTimeoutMillis == PARAMETER_NOT_SET_EXPLICITLY ? 0 : connectTimeoutMillis,
15701574
connectionListener,
1571-
actualConnectionListenerInboxCapacity
1575+
actualConnectionListenerInboxCapacity,
1576+
actualMaxFrameRejections
15721577
);
15731578
} catch (Throwable t) {
15741579
// connect() failed before ownership of cursorEngine
@@ -2417,6 +2422,27 @@ public LineSenderBuilder reconnectInitialBackoffMillis(long millis) {
24172422
return this;
24182423
}
24192424

2425+
/**
2426+
* Poison-frame detector threshold: consecutive server rejections
2427+
* (retriable NACK, or non-orderly close after a send) of the SAME
2428+
* head-of-line frame, with no ack progress in between, before the
2429+
* sender declares the frame poisoned and latches a typed terminal
2430+
* instead of reconnect-replaying forever. Retriable rejections below
2431+
* the threshold recycle the connection and replay from the
2432+
* store-and-forward log — no data is dropped either way.
2433+
* Default {@code 4}. WebSocket only.
2434+
*/
2435+
public LineSenderBuilder maxFrameRejections(int rejections) {
2436+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
2437+
throw new LineSenderException("max_frame_rejections is only supported for WebSocket transport");
2438+
}
2439+
if (rejections < 1) {
2440+
throw new LineSenderException("max_frame_rejections must be >= 1: ").put(rejections);
2441+
}
2442+
this.maxFrameRejections = rejections;
2443+
return this;
2444+
}
2445+
24202446
/**
24212447
* Max reconnect backoff in millis. Caps the exponential growth so
24222448
* a long outage doesn't end up sleeping minutes between attempts.
@@ -3342,6 +3368,12 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
33423368
}
33433369
pos = getValue(configurationString, pos, sink, "reconnect_initial_backoff_millis");
33443370
reconnectInitialBackoffMillis(parseLongValue(sink, "reconnect_initial_backoff_millis"));
3371+
} else if (Chars.equals("max_frame_rejections", sink)) {
3372+
if (protocol != PROTOCOL_WEBSOCKET) {
3373+
throw new LineSenderException("max_frame_rejections is only supported for WebSocket transport");
3374+
}
3375+
pos = getValue(configurationString, pos, sink, "max_frame_rejections");
3376+
maxFrameRejections(parseIntValue(sink, "max_frame_rejections"));
33453377
} else if (Chars.equals("initial_connect_retry", sink)) {
33463378
if (protocol != PROTOCOL_WEBSOCKET) {
33473379
throw new LineSenderException("initial_connect_retry is only supported for WebSocket transport");
@@ -3607,6 +3639,9 @@ private LineSenderBuilder fromConfigWebSocket(CharSequence configurationString)
36073639
if (view.has("reconnect_max_backoff_millis")) {
36083640
reconnectMaxBackoffMillis(wsLong(view, v, "reconnect_max_backoff_millis"));
36093641
}
3642+
if (view.has("max_frame_rejections")) {
3643+
maxFrameRejections(wsInt(view, v, "max_frame_rejections"));
3644+
}
36103645
if (view.has("sf_append_deadline_millis")) {
36113646
sfAppendDeadlineMillis(wsLong(view, v, "sf_append_deadline_millis"));
36123647
}
@@ -3781,6 +3816,7 @@ public java.util.Map<String, Object> wsConfigSnapshotForTest() {
37813816
m.put("reconnect_max_backoff_millis", reconnectMaxBackoffMillis);
37823817
m.put("drain_orphans", drainOrphans);
37833818
m.put("max_background_drainers", maxBackgroundDrainers);
3819+
m.put("max_frame_rejections", maxFrameRejections);
37843820
m.put("error_inbox_capacity", errorInboxCapacity);
37853821
m.put("connection_listener_inbox_capacity", connectionListenerInboxCapacity);
37863822
m.put("token", httpToken);

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public enum Category {
205205
/**
206206
* A frame the server (or an intermediary) deterministically rejects: the
207207
* poison-frame detector observed the same head-of-line frame fail
208-
* {@link io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop#MAX_HEAD_FRAME_REJECTIONS}
208+
* {@link io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop#DEFAULT_MAX_HEAD_FRAME_REJECTIONS}
209209
* consecutive times with no ack progress — replaying it cannot succeed.
210210
*/
211211
PROTOCOL_VIOLATION,

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

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ public class QwpWebSocketSender implements Sender {
281281
// Async-delivery sink for ack-watermark advances. Default no-op; a
282282
// setProgressHandler call before connect() swaps in a real handler.
283283
private SenderProgressHandler progressHandler = DefaultSenderProgressHandler.INSTANCE;
284+
// Poison-frame detector threshold forwarded to the cursor send loop and to
285+
// every background drainer (connect-string key max_frame_rejections).
286+
private int maxFrameRejections = CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS;
284287
private long reconnectInitialBackoffMillis =
285288
CursorWebSocketSendLoop.DEFAULT_RECONNECT_INITIAL_BACKOFF_MILLIS;
286289
private long reconnectMaxBackoffMillis =
@@ -620,7 +623,8 @@ public static QwpWebSocketSender connect(
620623

621624
/**
622625
* Multi-endpoint variant that also accepts the async connection-event
623-
* listener and its dispatcher inbox capacity.
626+
* listener and its dispatcher inbox capacity. Uses the default
627+
* poison-frame detector threshold.
624628
*/
625629
public static QwpWebSocketSender connect(
626630
List<Endpoint> endpoints,
@@ -643,6 +647,45 @@ public static QwpWebSocketSender connect(
643647
int connectTimeoutMs,
644648
SenderConnectionListener connectionListener,
645649
int connectionListenerInboxCapacity
650+
) {
651+
return connect(endpoints, tlsConfig, autoFlushRows, autoFlushBytes,
652+
autoFlushIntervalNanos, authorizationHeader, requestDurableAck,
653+
cursorEngine, closeFlushTimeoutMillis, reconnectMaxDurationMillis,
654+
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
655+
initialConnectMode, errorHandler, errorInboxCapacity,
656+
durableAckKeepaliveIntervalMillis, authTimeoutMs, connectTimeoutMs,
657+
connectionListener, connectionListenerInboxCapacity,
658+
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
659+
}
660+
661+
/**
662+
* Master connect overload — also accepts the poison-frame detector
663+
* threshold ({@code max_frame_rejections}): consecutive server-active
664+
* rejections of the same head-of-line frame, with no ack progress in
665+
* between, before the loop escalates to a typed terminal.
666+
*/
667+
public static QwpWebSocketSender connect(
668+
List<Endpoint> endpoints,
669+
ClientTlsConfiguration tlsConfig,
670+
int autoFlushRows,
671+
int autoFlushBytes,
672+
long autoFlushIntervalNanos,
673+
String authorizationHeader,
674+
boolean requestDurableAck,
675+
CursorSendEngine cursorEngine,
676+
long closeFlushTimeoutMillis,
677+
long reconnectMaxDurationMillis,
678+
long reconnectInitialBackoffMillis,
679+
long reconnectMaxBackoffMillis,
680+
Sender.InitialConnectMode initialConnectMode,
681+
SenderErrorHandler errorHandler,
682+
int errorInboxCapacity,
683+
long durableAckKeepaliveIntervalMillis,
684+
long authTimeoutMs,
685+
int connectTimeoutMs,
686+
SenderConnectionListener connectionListener,
687+
int connectionListenerInboxCapacity,
688+
int maxFrameRejections
646689
) {
647690
QwpWebSocketSender sender = new QwpWebSocketSender(
648691
endpoints, tlsConfig,
@@ -658,6 +701,7 @@ public static QwpWebSocketSender connect(
658701
sender.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
659702
sender.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
660703
sender.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
704+
sender.maxFrameRejections = maxFrameRejections;
661705
sender.initialConnectMode = initialConnectMode == null
662706
? Sender.InitialConnectMode.OFF
663707
: initialConnectMode;
@@ -2308,7 +2352,8 @@ public synchronized void startOrphanDrainers(
23082352
reconnectInitialBackoffMillis,
23092353
reconnectMaxBackoffMillis,
23102354
requestDurableAck,
2311-
durableAckKeepaliveIntervalMillis);
2355+
durableAckKeepaliveIntervalMillis,
2356+
maxFrameRejections);
23122357
ref[0] = drainer;
23132358
drainerPool.submit(drainer);
23142359
}
@@ -3158,7 +3203,8 @@ private void ensureConnected() {
31583203
reconnectInitialBackoffMillis,
31593204
reconnectMaxBackoffMillis,
31603205
requestDurableAck,
3161-
durableAckKeepaliveIntervalMillis);
3206+
durableAckKeepaliveIntervalMillis,
3207+
maxFrameRejections);
31623208
// Plug the async-delivery sink before start() so the I/O thread
31633209
// never observes a null dispatcher between recordFatal and
31643210
// notification — the test for null in dispatchError handles

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ public final class BackgroundDrainer implements Runnable {
126126
*/
127127
private volatile Thread runnerThread;
128128
private volatile boolean stopRequested;
129+
// Poison-frame detector threshold forwarded to every drain loop this
130+
// drainer creates; mirrors the owner sender's max_frame_rejections config.
131+
private final int maxHeadFrameRejections;
129132

130133
public BackgroundDrainer(
131134
String slotPath,
@@ -137,6 +140,31 @@ public BackgroundDrainer(
137140
long reconnectMaxBackoffMillis,
138141
boolean requestDurableAck,
139142
long durableAckKeepaliveIntervalMillis
143+
) {
144+
this(slotPath, segmentSizeBytes, sfMaxTotalBytes, clientFactory,
145+
reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
146+
reconnectMaxBackoffMillis, requestDurableAck,
147+
durableAckKeepaliveIntervalMillis,
148+
CursorWebSocketSendLoop.DEFAULT_MAX_HEAD_FRAME_REJECTIONS);
149+
}
150+
151+
/**
152+
* Master constructor — also accepts the poison-frame detector threshold
153+
* ({@code max_frame_rejections}) forwarded to the drain loop's
154+
* {@link CursorWebSocketSendLoop}: the drainer replays the owner sender's
155+
* SF data, so it must honor the same configured threshold.
156+
*/
157+
public BackgroundDrainer(
158+
String slotPath,
159+
long segmentSizeBytes,
160+
long sfMaxTotalBytes,
161+
CursorWebSocketSendLoop.ReconnectFactory clientFactory,
162+
long reconnectMaxDurationMillis,
163+
long reconnectInitialBackoffMillis,
164+
long reconnectMaxBackoffMillis,
165+
boolean requestDurableAck,
166+
long durableAckKeepaliveIntervalMillis,
167+
int maxHeadFrameRejections
140168
) {
141169
this.slotPath = slotPath;
142170
this.segmentSizeBytes = segmentSizeBytes;
@@ -147,6 +175,7 @@ public BackgroundDrainer(
147175
this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
148176
this.requestDurableAck = requestDurableAck;
149177
this.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
178+
this.maxHeadFrameRejections = maxHeadFrameRejections;
150179
}
151180

152181
/**
@@ -506,7 +535,8 @@ public void run() {
506535
reconnectInitialBackoffMillis,
507536
reconnectMaxBackoffMillis,
508537
requestDurableAck,
509-
durableAckKeepaliveIntervalMillis);
538+
durableAckKeepaliveIntervalMillis,
539+
maxHeadFrameRejections);
510540
loop.start();
511541

512542
while (!stopRequested) {

0 commit comments

Comments
 (0)