Skip to content

Commit 45f4ae5

Browse files
bluestreak01claude
andcommitted
Send keepalive PINGs when waiting on durable-ack confirmations
The server's QWP egress only runs flushPendingAck on inbound recv events: handleBinaryMessage, handlePing, close. Without inbound traffic from the client, an idle connection that has finished publishing its data sits forever waiting for STATUS_DURABLE_ACK frames that the server has the data for but no event-loop trigger to send. The legacy QwpWebSocketSender exposed a ping() API for the same purpose; the SF cursor sender is supposed to be opaque to the user, so put the prod inside the I/O loop. When durableAckMode is on and pendingDurable is non-empty, the loop sends a WebSocket PING every 200ms while otherwise idle. The throttle bounds the cost at one PING per 200ms per opted-in connection, only while actually waiting on acks. The ping resets on reconnect via clearDurableAckTracking() so a fresh connection can prod the server immediately. This is a stopgap. The right server-side fix is for the egress processor to flush durable-ack frames on its own schedule -- either a per-connection idle tick or a registry-driven wake of the affected connection's event loop. Both require touching the HTTP server framework's dispatch model. The client-side keepalive lets the durable-ack tests on vi_sf pass without that work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent de8ba19 commit 45f4ae5

1 file changed

Lines changed: 53 additions & 0 deletions

File tree

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
7979
public static final long DEFAULT_RECONNECT_MAX_BACKOFF_MILLIS = 5_000L;
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;
82+
/**
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.
93+
*/
94+
private static final long DURABLE_ACK_KEEPALIVE_PING_NANOS = 200_000_000L;
8295
private static final Logger LOG = LoggerFactory.getLogger(CursorWebSocketSendLoop.class);
8396

8497
private final AtomicLong consecutiveSendErrors = new AtomicLong();
@@ -149,6 +162,10 @@ public final class CursorWebSocketSendLoop implements QuietCloseable {
149162
// when durableAckMode is false.
150163
private final AtomicLong totalDurableAcks = new AtomicLong();
151164
private final AtomicLong totalDurableTrimAdvances = new AtomicLong();
165+
// Wall clock of the last keepalive PING the I/O loop sent to prod the
166+
// server into flushing durable-ack frames. Zero until the first PING.
167+
// See DURABLE_ACK_KEEPALIVE_PING_NANOS for the rationale.
168+
private long lastKeepalivePingNanos;
152169
private WebSocketClient client;
153170
// fsnAtZero: FSN that wireSeq=0 maps to on the current connection. For
154171
// a fresh connection, this is 0. After a reconnect, it's set to
@@ -813,6 +830,10 @@ private void clearDurableAckTracking() {
813830
releasePendingEntry(pendingDurable.pollFirst());
814831
}
815832
durableTableWatermarks.clear();
833+
// Reset the keepalive throttle so the new connection can prod the
834+
// server immediately rather than waiting out the leftover interval
835+
// from before the reconnect.
836+
lastKeepalivePingNanos = 0L;
816837
}
817838

818839
/**
@@ -862,6 +883,14 @@ private void ioLoop() {
862883
if (tryReceiveAcks()) {
863884
didWork = true;
864885
}
886+
// 3. In durable-ack mode, prod the server with a keepalive
887+
// PING when there are pending OKs awaiting confirmation
888+
// and we have nothing else to do. The server only flushes
889+
// durable-ack frames on inbound traffic, so an idle
890+
// client otherwise never sees the WAL-upload completion.
891+
if (!didWork && running && durableAckMode && !pendingDurable.isEmpty()) {
892+
sendDurableAckKeepaliveIfDue();
893+
}
865894
if (!didWork && running) {
866895
LockSupport.parkNanos(parkNanos);
867896
}
@@ -941,6 +970,30 @@ private boolean tryReceiveAcks() {
941970
return any;
942971
}
943972

973+
/**
974+
* Send a WebSocket PING to prod the server into flushing pending
975+
* STATUS_DURABLE_ACK frames, but only when the throttle interval has
976+
* elapsed since the last keepalive PING. The server's egress code only
977+
* runs flushPendingAck on inbound recv events; without this prod, an
978+
* idle connection waiting on durable-ack confirmation can sit forever.
979+
* <p>
980+
* Best-effort: any send failure routes through the standard fail() path
981+
* so the reconnect loop can take over. Caller is responsible for the
982+
* "do we even need to send" gate (durableAckMode + non-empty pending).
983+
*/
984+
private void sendDurableAckKeepaliveIfDue() {
985+
long now = System.nanoTime();
986+
if (now - lastKeepalivePingNanos < DURABLE_ACK_KEEPALIVE_PING_NANOS) {
987+
return;
988+
}
989+
lastKeepalivePingNanos = now;
990+
try {
991+
client.sendPing(1000);
992+
} catch (Throwable t) {
993+
fail(t);
994+
}
995+
}
996+
944997
/** Inner ACK handler — parses the binary frame, calls engine.acknowledge. */
945998
private final class ResponseHandler implements WebSocketFrameHandler {
946999
@Override

0 commit comments

Comments
 (0)