Skip to content

Commit 12fc150

Browse files
bluestreak01claude
andcommitted
Make durable-ack keepalive PING cadence configurable
Expose `durable_ack_keepalive_interval_millis` on the connect string and the LineSenderBuilder. Default stays at 200 ms (matches the previously hardcoded constant). 0 or negative disables the keepalive entirely, for callers who send continuous traffic that already prods the server, or who run their own ping driver. WebSocket transport only — TCP path raises a LineSenderException if the knob is set, mirroring how the other QWP-only knobs guard themselves. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 45f4ae5 commit 12fc150

4 files changed

Lines changed: 182 additions & 16 deletions

File tree

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,12 @@ public int getTimeout() {
724724
private long reconnectMaxDurationMillis = PARAMETER_NOT_SET_EXPLICITLY;
725725
private long reconnectInitialBackoffMillis = PARAMETER_NOT_SET_EXPLICITLY;
726726
private long reconnectMaxBackoffMillis = PARAMETER_NOT_SET_EXPLICITLY;
727+
// Cadence at which the SF cursor I/O loop sends a keepalive PING
728+
// while waiting on STATUS_DURABLE_ACK frames. Default applied at
729+
// build() time. 0 or negative is a documented "disable" value, so
730+
// a Long.MIN_VALUE sentinel keeps it distinguishable from "unset".
731+
private static final long DURABLE_ACK_KEEPALIVE_NOT_SET = Long.MIN_VALUE;
732+
private long durableAckKeepaliveIntervalMillis = DURABLE_ACK_KEEPALIVE_NOT_SET;
727733
// Drives the initial-connect strategy. OFF is fail-fast (default).
728734
// SYNC retries on the user thread up to the reconnect cap. ASYNC
729735
// returns immediately and lets the I/O thread retry in the
@@ -1091,6 +1097,10 @@ public Sender build() {
10911097
reconnectMaxBackoffMillis == PARAMETER_NOT_SET_EXPLICITLY
10921098
? CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS
10931099
: reconnectMaxBackoffMillis;
1100+
long actualDurableAckKeepaliveIntervalMillis =
1101+
durableAckKeepaliveIntervalMillis == DURABLE_ACK_KEEPALIVE_NOT_SET
1102+
? CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS
1103+
: durableAckKeepaliveIntervalMillis;
10941104

10951105
// sfDir is the parent (group root); the actual slot lives
10961106
// under sfDir/senderId. This is what the engine sees — the
@@ -1144,7 +1154,8 @@ public Sender build() {
11441154
actualReconnectMaxBackoffMillis,
11451155
initialConnectMode,
11461156
errorHandler,
1147-
actualErrorInboxCapacity
1157+
actualErrorInboxCapacity,
1158+
actualDurableAckKeepaliveIntervalMillis
11481159
);
11491160
} catch (Throwable t) {
11501161
// connect() failed before ownership of cursorEngine
@@ -1934,6 +1945,32 @@ public LineSenderBuilder closeFlushTimeoutMillis(long timeoutMillis) {
19341945
return this;
19351946
}
19361947

1948+
/**
1949+
* Cadence (in millis) at which the SF cursor I/O loop sends a
1950+
* keepalive PING while waiting on STATUS_DURABLE_ACK frames. The
1951+
* server only flushes pending durable-ack frames in response to
1952+
* inbound recv events, so an idle opted-in client needs to prod
1953+
* the server periodically. Effective only when
1954+
* {@link #requestDurableAck(boolean) request_durable_ack=on}.
1955+
* <p>
1956+
* Pass {@code 0} or negative to disable keepalive PINGs entirely
1957+
* — the caller then accepts that durable-ack frames may not
1958+
* arrive on idle connections (e.g. they are sending data
1959+
* continuously, or have their own ping driver).
1960+
* <p>
1961+
* Default
1962+
* {@value io.questdb.client.cutlass.qwp.client.sf.cursor.CursorWebSocketSendLoop#DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS}
1963+
* ms. WebSocket transport only.
1964+
*/
1965+
public LineSenderBuilder durableAckKeepaliveIntervalMillis(long millis) {
1966+
if (protocol != PARAMETER_NOT_SET_EXPLICITLY && protocol != PROTOCOL_WEBSOCKET) {
1967+
throw new LineSenderException(
1968+
"durable_ack_keepalive_interval_millis is only supported for WebSocket transport");
1969+
}
1970+
this.durableAckKeepaliveIntervalMillis = millis;
1971+
return this;
1972+
}
1973+
19371974
/**
19381975
* Per-outage cap on the cursor I/O loop's reconnect retry budget.
19391976
* Once a wire failure occurs, the loop retries with exponential
@@ -2629,6 +2666,13 @@ private LineSenderBuilder fromConfig(CharSequence configurationString) {
26292666
}
26302667
pos = getValue(configurationString, pos, sink, "close_flush_timeout_millis");
26312668
closeFlushTimeoutMillis(parseLongValue(sink, "close_flush_timeout_millis"));
2669+
} else if (Chars.equals("durable_ack_keepalive_interval_millis", sink)) {
2670+
if (protocol != PROTOCOL_WEBSOCKET) {
2671+
throw new LineSenderException(
2672+
"durable_ack_keepalive_interval_millis is only supported for WebSocket transport");
2673+
}
2674+
pos = getValue(configurationString, pos, sink, "durable_ack_keepalive_interval_millis");
2675+
durableAckKeepaliveIntervalMillis(parseLongValue(sink, "durable_ack_keepalive_interval_millis"));
26322676
} else if (Chars.equals("reconnect_max_duration_millis", sink)) {
26332677
if (protocol != PROTOCOL_WEBSOCKET) {
26342678
throw new LineSenderException("reconnect_max_duration_millis is only supported for WebSocket transport");

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ public class QwpWebSocketSender implements Sender {
204204
private long reconnectMaxDurationMillis =
205205
CursorWebSocketSendLoop.DEFAULT_RECONNECT_MAX_DURATION_MILLIS;
206206
private boolean requestDurableAck;
207+
// Keepalive PING cadence used by the I/O loop while
208+
// request_durable_ack=on AND there are pending durable-ack
209+
// confirmations. Default mirrors the loop's spec value; 0 or negative
210+
// disables keepalive PINGs entirely.
211+
private long durableAckKeepaliveIntervalMillis =
212+
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS;
207213

208214
private QwpWebSocketSender(
209215
String host,
@@ -434,6 +440,42 @@ public static QwpWebSocketSender connect(
434440
Sender.InitialConnectMode initialConnectMode,
435441
SenderErrorHandler errorHandler,
436442
int errorInboxCapacity
443+
) {
444+
return connect(host, port, tlsConfig, autoFlushRows, autoFlushBytes,
445+
autoFlushIntervalNanos, inFlightWindowSize, authorizationHeader,
446+
maxSchemasPerConnection, requestDurableAck, cursorEngine,
447+
closeFlushTimeoutMillis, reconnectMaxDurationMillis,
448+
reconnectInitialBackoffMillis, reconnectMaxBackoffMillis,
449+
initialConnectMode, errorHandler, errorInboxCapacity,
450+
CursorWebSocketSendLoop.DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS);
451+
}
452+
453+
/**
454+
* Master connect overload — also accepts the keepalive PING cadence
455+
* the I/O loop uses while waiting on STATUS_DURABLE_ACK frames.
456+
* {@code 0} or negative disables the keepalive entirely (caller takes
457+
* responsibility for prodding the server).
458+
*/
459+
public static QwpWebSocketSender connect(
460+
String host,
461+
int port,
462+
ClientTlsConfiguration tlsConfig,
463+
int autoFlushRows,
464+
int autoFlushBytes,
465+
long autoFlushIntervalNanos,
466+
int inFlightWindowSize,
467+
String authorizationHeader,
468+
int maxSchemasPerConnection,
469+
boolean requestDurableAck,
470+
CursorSendEngine cursorEngine,
471+
long closeFlushTimeoutMillis,
472+
long reconnectMaxDurationMillis,
473+
long reconnectInitialBackoffMillis,
474+
long reconnectMaxBackoffMillis,
475+
Sender.InitialConnectMode initialConnectMode,
476+
SenderErrorHandler errorHandler,
477+
int errorInboxCapacity,
478+
long durableAckKeepaliveIntervalMillis
437479
) {
438480
QwpWebSocketSender sender = new QwpWebSocketSender(
439481
host, port, tlsConfig,
@@ -446,6 +488,7 @@ public static QwpWebSocketSender connect(
446488
sender.reconnectMaxDurationMillis = reconnectMaxDurationMillis;
447489
sender.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
448490
sender.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
491+
sender.durableAckKeepaliveIntervalMillis = durableAckKeepaliveIntervalMillis;
449492
sender.initialConnectMode = initialConnectMode == null
450493
? Sender.InitialConnectMode.OFF
451494
: initialConnectMode;
@@ -1977,7 +2020,8 @@ private void ensureConnected() {
19772020
reconnectMaxDurationMillis,
19782021
reconnectInitialBackoffMillis,
19792022
reconnectMaxBackoffMillis,
1980-
requestDurableAck);
2023+
requestDurableAck,
2024+
durableAckKeepaliveIntervalMillis);
19812025
// Plug the async-delivery sink before start() so the I/O thread
19822026
// never observes a null dispatcher between recordFatal and
19832027
// notification — the test for null in dispatchError handles

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

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,16 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
8080
/** Throttle "reconnect attempt N failed" WARN logs to one per 5 s. */
8181
private static final long RECONNECT_LOG_THROTTLE_NANOS = 5_000_000_000L;
8282
/**
83-
* In durable-ack mode, the server only flushes pending STATUS_DURABLE_ACK
84-
* frames in response to inbound recv events (handleBinaryMessage,
85-
* handlePing, close). Without inbound traffic from the client, an idle
86-
* connection that has finished publishing data never sees the ack frame
87-
* even after the WAL upload has completed server-side. The I/O loop
88-
* sends a WebSocket PING at this cadence whenever pendingDurable is
89-
* non-empty and the loop is otherwise idle, which prods the server to
90-
* call flushPendingAck and emit any progress that has accumulated. The
91-
* cost is one PING per 200ms per opted-in connection, only while the
92-
* client is actually waiting on durable-acks.
83+
* Default cadence for the keepalive PING the I/O loop emits while
84+
* waiting on STATUS_DURABLE_ACK frames. See
85+
* {@link #sendDurableAckKeepaliveIfDue()} for the rationale: the OSS
86+
* server only flushes pending durable-ack frames on inbound recv
87+
* events, so an opted-in idle client has to prod it. {@code 200} ms
88+
* trades one PING per 200 ms per idle opted-in connection for
89+
* sub-second confirmation latency once the upload completes
90+
* server-side. {@code 0} or negative disables the keepalive entirely.
9391
*/
94-
private static final long DURABLE_ACK_KEEPALIVE_PING_NANOS = 200_000_000L;
92+
public static final long DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS = 200L;
9593
private static final Logger LOG = LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
9694

9795
private final AtomicLong consecutiveSendErrors = new AtomicLong();
@@ -158,13 +156,15 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
158156
// (default), the loop trims on OK as it always has and ignores any
159157
// STATUS_DURABLE_ACK frames that might still arrive (logs a warning).
160158
private final boolean durableAckMode;
159+
// Pre-converted to nanos for the comparison in sendDurableAckKeepaliveIfDue.
160+
// Zero or negative disables the keepalive entirely.
161+
private final long durableAckKeepaliveIntervalNanos;
161162
// Counters for observability of the durable-ack path. Both are zero
162163
// when durableAckMode is false.
163164
private final AtomicLong totalDurableAcks = new AtomicLong();
164165
private final AtomicLong totalDurableTrimAdvances = new AtomicLong();
165166
// Wall clock of the last keepalive PING the I/O loop sent to prod the
166167
// server into flushing durable-ack frames. Zero until the first PING.
167-
// See DURABLE_ACK_KEEPALIVE_PING_NANOS for the rationale.
168168
private long lastKeepalivePingNanos;
169169
private WebSocketClient client;
170170
// fsnAtZero: FSN that wireSeq=0 maps to on the current connection. For
@@ -239,6 +239,27 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
239239
long reconnectInitialBackoffMillis,
240240
long reconnectMaxBackoffMillis,
241241
boolean durableAckMode) {
242+
this(client, engine, fsnAtZero, parkNanos, reconnectFactory,
243+
reconnectMaxDurationMillis, reconnectInitialBackoffMillis,
244+
reconnectMaxBackoffMillis, durableAckMode,
245+
DEFAULT_DURABLE_ACK_KEEPALIVE_INTERVAL_MILLIS);
246+
}
247+
248+
/**
249+
* Master constructor — also accepts the cadence at which the I/O loop
250+
* sends keepalive PINGs while waiting on STATUS_DURABLE_ACK frames.
251+
* Pass {@code 0} or negative to disable keepalive PINGs entirely (the
252+
* caller takes responsibility for prodding the server, e.g. by sending
253+
* data, or by accepting indefinite waits on idle connections).
254+
*/
255+
public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
256+
long fsnAtZero, long parkNanos,
257+
ReconnectFactory reconnectFactory,
258+
long reconnectMaxDurationMillis,
259+
long reconnectInitialBackoffMillis,
260+
long reconnectMaxBackoffMillis,
261+
boolean durableAckMode,
262+
long durableAckKeepaliveIntervalMillis) {
242263
if (engine == null) {
243264
throw new IllegalArgumentException("engine must be non-null");
244265
}
@@ -255,6 +276,9 @@ public CursorWebSocketSendLoop(WebSocketClient client, CursorSendEngine engine,
255276
this.reconnectInitialBackoffMillis = reconnectInitialBackoffMillis;
256277
this.reconnectMaxBackoffMillis = reconnectMaxBackoffMillis;
257278
this.durableAckMode = durableAckMode;
279+
this.durableAckKeepaliveIntervalNanos = durableAckKeepaliveIntervalMillis > 0
280+
? durableAckKeepaliveIntervalMillis * 1_000_000L
281+
: 0L;
258282
// SYNC/OFF startup hands a live client to the constructor, so we
259283
// already know we reached the server at least once. ASYNC startup
260284
// hands null and lets the I/O thread connect — hasEverConnected
@@ -888,7 +912,11 @@ private void ioLoop() {
888912
// and we have nothing else to do. The server only flushes
889913
// durable-ack frames on inbound traffic, so an idle
890914
// client otherwise never sees the WAL-upload completion.
891-
if (!didWork && running && durableAckMode && !pendingDurable.isEmpty()) {
915+
// Skipped entirely when the user has disabled the
916+
// keepalive (interval <= 0).
917+
if (!didWork && running && durableAckMode
918+
&& durableAckKeepaliveIntervalNanos > 0L
919+
&& !pendingDurable.isEmpty()) {
892920
sendDurableAckKeepaliveIfDue();
893921
}
894922
if (!didWork && running) {
@@ -983,7 +1011,7 @@ private boolean tryReceiveAcks() {
9831011
*/
9841012
private void sendDurableAckKeepaliveIfDue() {
9851013
long now = System.nanoTime();
986-
if (now - lastKeepalivePingNanos < DURABLE_ACK_KEEPALIVE_PING_NANOS) {
1014+
if (now - lastKeepalivePingNanos < durableAckKeepaliveIntervalNanos) {
9871015
return;
9881016
}
9891017
lastKeepalivePingNanos = now;

core/src/test/java/io/questdb/client/test/cutlass/qwp/client/LineSenderBuilderWebSocketTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,56 @@ public void testEnableAuth_notSupported() {
289289
"not supported for WebSocket");
290290
}
291291

292+
@Test
293+
public void testDurableAckKeepaliveIntervalAcceptedOnWebSocket() {
294+
// Positive value, zero (disable), and negative (also disable) all
295+
// build cleanly. The setter is just a setter; semantics are validated
296+
// at I/O-loop construction time.
297+
Assert.assertNotNull(Sender.builder(Sender.Transport.WEBSOCKET)
298+
.address(LOCALHOST)
299+
.durableAckKeepaliveIntervalMillis(50));
300+
Assert.assertNotNull(Sender.builder(Sender.Transport.WEBSOCKET)
301+
.address(LOCALHOST)
302+
.durableAckKeepaliveIntervalMillis(0));
303+
Assert.assertNotNull(Sender.builder(Sender.Transport.WEBSOCKET)
304+
.address(LOCALHOST)
305+
.durableAckKeepaliveIntervalMillis(-1));
306+
}
307+
308+
@Test
309+
public void testDurableAckKeepaliveIntervalConfigStringParses() {
310+
// Smoke test: the parser accepts the knob, including the disable-by-zero
311+
// value, without erroring at config-string construction. The full
312+
// end-to-end behaviour (server interaction) is covered by
313+
// ReplicationTest in the parent repo, which exercises the default
314+
// value. fromConfig() is allowed to throw a LineSenderException at
315+
// connect time -- we only assert the parser did not reject the key.
316+
try {
317+
Sender.fromConfig("ws::addr=127.0.0.1:1;durable_ack_keepalive_interval_millis=50;");
318+
} catch (LineSenderException e) {
319+
Assert.assertFalse("parser must not reject the key, was: " + e.getMessage(),
320+
e.getMessage().contains("durable_ack_keepalive_interval_millis"));
321+
}
322+
try {
323+
Sender.fromConfig("ws::addr=127.0.0.1:1;durable_ack_keepalive_interval_millis=0;");
324+
} catch (LineSenderException e) {
325+
Assert.assertFalse("parser must accept zero (disable), was: " + e.getMessage(),
326+
e.getMessage().contains("durable_ack_keepalive_interval_millis"));
327+
}
328+
}
329+
330+
@Test
331+
public void testDurableAckKeepaliveIntervalNotSupportedForTcp() {
332+
// The knob is WebSocket-only; the TCP-protocol path must reject it
333+
// loudly so a user who copy-pasted the param into the wrong transport
334+
// sees the mismatch immediately rather than silently losing the
335+
// intended behaviour.
336+
assertThrows("durable_ack_keepalive_interval_millis is only supported for WebSocket transport",
337+
() -> Sender.builder(Sender.Transport.TCP)
338+
.address(LOCALHOST)
339+
.durableAckKeepaliveIntervalMillis(100));
340+
}
341+
292342
@Test
293343
public void testFullAsyncConfiguration() {
294344
Sender.LineSenderBuilder builder = Sender.builder(Sender.Transport.WEBSOCKET)

0 commit comments

Comments
 (0)