Skip to content

Commit 21d885b

Browse files
bluestreak01claude
andcommitted
Defer cursor SF trim to durable-ack when opted in
Add request_durable_ack=on opt-in to the cursor store-and-forward sender. When opted in, OK frames no longer advance the trim watermark on the on-disk SF; only STATUS_DURABLE_ACK frames do. This closes the last data-loss window in the QWP ingestion path: with OK-driven trim, a primary that committed to its WAL but failed before uploading to object store could lose rows whose only remaining copy was already trimmed from the client's SF. The opt-in negotiates with the server through the WebSocket upgrade. The client sends X-QWP-Request-Durable-Ack: true in the upgrade request, and the server echoes X-QWP-Durable-Ack: enabled in the 101 response when (and only when) it has a durable-ack registry. If the client opted in but the server did not echo the confirmation, connect fails immediately rather than letting the SF grow until the disk fills -- a loud failure beats silent storage exhaustion. Trim accounting tracks per-table seqTxn watermarks in CursorWebSocketSendLoop. OK frames enqueue (wireSeq, table list, seqTxns) into a FIFO; durable-ack frames update watermarks and drain the FIFO head whenever every table referenced by the head entry is at or above its watermark. Empty OKs (zero tables) and DROP_AND_CONTINUE rejections are trivially durable so the queue stays drained. swap on reconnect clears the queue and watermarks; the post-reconnect replay restores them as the server re-OKs the same wireSeq range. Tests: - 13 unit tests in CursorWebSocketSendLoopDurableAckTest cover OK-only growth, durable-ack drain, multi-table cumulative drain, partial vs full coverage, NACK handling, backwards-watermark rejection, and reconnect state reset. - 4 integration tests in DurableAckIntegrationTest exercise the connect-string parser (invalid value rejected, off opts out cleanly, on requires server support) and end-to-end deferred-trim through a TestWebSocketServer. - CursorWebSocketSendLoopDurableAckFuzzTest runs 500 iterations of randomised OK/durable-ack interleavings and asserts the watermark invariants on every iteration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a6b45c3 commit 21d885b

7 files changed

Lines changed: 1469 additions & 13 deletions

File tree

core/src/main/java/io/questdb/client/cutlass/http/client/WebSocketClient.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ public abstract class WebSocketClient implements QuietCloseable {
7575
private static final int PARSE_INCOMPLETE = 0;
7676
private static final int PARSE_NEED_MORE = -1;
7777
private static final int PARSE_OK = 1;
78+
private static final String QWP_DURABLE_ACK_ENABLED_VALUE = "enabled";
79+
private static final String QWP_DURABLE_ACK_HEADER_NAME = "X-QWP-Durable-Ack:";
7880
private static final String QWP_VERSION_HEADER_NAME = "X-QWP-Version:";
7981
private static final ThreadLocal<MessageDigest> SHA1_DIGEST = ThreadLocal.withInitial(() -> {
8082
try {
@@ -124,6 +126,12 @@ public abstract class WebSocketClient implements QuietCloseable {
124126
private int recvBufSize;
125127
private int recvPos; // Write position
126128
private int recvReadPos; // Read position
129+
// Set during upgrade response validation when the server echoed
130+
// X-QWP-Durable-Ack: enabled. Tells the sender it landed on a server that
131+
// will actually emit STATUS_DURABLE_ACK frames, so its store-and-forward
132+
// path can rely on durable-ack-driven trim. Absence (after opting in via
133+
// setQwpRequestDurableAck) is the early-fail signal.
134+
private boolean serverDurableAckEnabled;
127135
private int serverQwpVersion = 1;
128136
private boolean upgraded;
129137

@@ -295,6 +303,16 @@ public boolean isConnected() {
295303
return upgraded && !closed && !socket.isClosed();
296304
}
297305

306+
/**
307+
* Returns true when the server echoed X-QWP-Durable-Ack: enabled in the
308+
* 101 upgrade response. Meaningful only after {@link #upgrade} returns;
309+
* always false when the client did not opt in via
310+
* {@link #setQwpRequestDurableAck}.
311+
*/
312+
public boolean isServerDurableAckEnabled() {
313+
return serverDurableAckEnabled;
314+
}
315+
298316
/**
299317
* Receives and processes WebSocket frames.
300318
*
@@ -589,6 +607,23 @@ private static boolean excludesHeaderValue(String response, String headerName, S
589607
return true;
590608
}
591609

610+
private static boolean extractDurableAckEnabled(String response) {
611+
int headerLen = QWP_DURABLE_ACK_HEADER_NAME.length();
612+
int responseLen = response.length();
613+
for (int i = 0; i <= responseLen - headerLen; i++) {
614+
if (response.regionMatches(true, i, QWP_DURABLE_ACK_HEADER_NAME, 0, headerLen)) {
615+
int valueStart = i + headerLen;
616+
int lineEnd = response.indexOf('\r', valueStart);
617+
if (lineEnd < 0) {
618+
lineEnd = responseLen;
619+
}
620+
String value = response.substring(valueStart, lineEnd).trim();
621+
return value.equalsIgnoreCase(QWP_DURABLE_ACK_ENABLED_VALUE);
622+
}
623+
}
624+
return false;
625+
}
626+
592627
private static int extractQwpVersion(String response) {
593628
int headerLen = QWP_VERSION_HEADER_NAME.length();
594629
int responseLen = response.length();
@@ -1017,6 +1052,13 @@ private void validateUpgradeResponse(int headerEnd) {
10171052

10181053
// Extract X-QWP-Version (optional, defaults to 1 if absent)
10191054
serverQwpVersion = extractQwpVersion(response);
1055+
1056+
// Extract X-QWP-Durable-Ack confirmation (optional, absent on servers
1057+
// without primary replication or when the client did not opt in).
1058+
// Only meaningful when qwpRequestDurableAck is true; the sender
1059+
// checks this value to fail at connect rather than silently
1060+
// missing trim signals.
1061+
serverDurableAckEnabled = extractDurableAckEnabled(response);
10201062
}
10211063

10221064
protected void dieWaiting(int n) {

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1766,7 +1766,8 @@ private void ensureConnected() {
17661766
this::buildAndConnect,
17671767
reconnectMaxDurationMillis,
17681768
reconnectInitialBackoffMillis,
1769-
reconnectMaxBackoffMillis);
1769+
reconnectMaxBackoffMillis,
1770+
requestDurableAck);
17701771
// Plug the async-delivery sink before start() so the I/O thread
17711772
// never observes a null dispatcher between recordFatal and
17721773
// notification — the test for null in dispatchError handles
@@ -1834,6 +1835,20 @@ private WebSocketClient buildAndConnect() {
18341835
newClient.close();
18351836
throw new LineSenderException("Failed to connect to " + host + ":" + port, e);
18361837
}
1838+
// Fail at connect when the user opted into durable acks but landed on
1839+
// a server that did not echo the X-QWP-Durable-Ack: enabled confirmation.
1840+
// Without this check, store-and-forward would never receive trim signals
1841+
// and the on-disk store would grow unbounded -- silent storage exhaustion
1842+
// is a worse outcome than a loud connect-time failure.
1843+
if (requestDurableAck && !newClient.isServerDurableAckEnabled()) {
1844+
newClient.close();
1845+
throw new LineSenderException(
1846+
"server does not support durable ack [host=" + host + ", port=" + port
1847+
+ "]. The client opted in via request_durable_ack=on but the server "
1848+
+ "did not echo X-QWP-Durable-Ack: enabled in the upgrade response. "
1849+
+ "Either disable request_durable_ack or connect to a server with "
1850+
+ "primary replication configured.");
1851+
}
18371852
return newClient;
18381853
}
18391854

0 commit comments

Comments
 (0)