Skip to content

Commit 5d32bef

Browse files
committed
feat(qwp): QWiP durable ack
1 parent 49f5f42 commit 5d32bef

8 files changed

Lines changed: 544 additions & 48 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ public abstract class WebSocketClient implements QuietCloseable {
107107
// QWP version negotiation
108108
private String qwpClientId;
109109
private int qwpMaxVersion = 1;
110+
// Opt-in for STATUS_DURABLE_ACK frames; sent as X-QWP-Request-Durable-Ack: true
111+
private boolean qwpRequestDurableAck;
110112
// Receive buffer (native memory)
111113
private long recvBufPtr;
112114
private int recvBufSize;
@@ -390,6 +392,16 @@ public void setQwpMaxVersion(int maxVersion) {
390392
this.qwpMaxVersion = maxVersion;
391393
}
392394

395+
/**
396+
* Enables the opt-in X-QWP-Request-Durable-Ack upgrade header. When set,
397+
* servers with primary replication configured will additionally emit
398+
* STATUS_DURABLE_ACK frames as the WAL containing committed client
399+
* messages reaches the object store.
400+
*/
401+
public void setQwpRequestDurableAck(boolean enabled) {
402+
this.qwpRequestDurableAck = enabled;
403+
}
404+
393405
/**
394406
* Non-blocking attempt to receive a WebSocket frame.
395407
* Returns immediately if no complete frame is available.
@@ -476,6 +488,9 @@ public void upgrade(CharSequence path, int timeout, CharSequence authorizationHe
476488
sendBuffer.putAscii(qwpClientId);
477489
sendBuffer.putAscii("\r\n");
478490
}
491+
if (qwpRequestDurableAck) {
492+
sendBuffer.putAscii("X-QWP-Request-Durable-Ack: true\r\n");
493+
}
479494
if (authorizationHeader != null) {
480495
sendBuffer.putAscii("Authorization: ");
481496
sendBuffer.putAscii(authorizationHeader);

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ public Throwable getLastError() {
377377
return lastError.get();
378378
}
379379

380+
/**
381+
* Returns the highest batch sequence acknowledged by the server, or -1 if
382+
* no acknowledgment has been received yet.
383+
*/
384+
public long getHighestAckedSequence() {
385+
return highestAcked;
386+
}
387+
380388
/**
381389
* Returns the maximum window size.
382390
*/

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

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ public class QwpWebSocketSender implements Sender {
138138
// Flow control
139139
private InFlightWindow inFlightWindow;
140140
private int maxSentSchemaId = -1;
141-
// Track highest symbol ID sent to server (for delta encoding)
141+
// Track the highest symbol ID sent to server (for delta encoding)
142142
// Once sent over TCP, server is guaranteed to receive it (or connection dies)
143143
private int maxSentSymbolId = -1;
144144
// Batch sequence counter (must match server's messageSequence)
@@ -147,7 +147,14 @@ public class QwpWebSocketSender implements Sender {
147147
// Async mode: pending row tracking
148148
private long pendingBytes;
149149
private int pendingRowCount;
150+
// Highest client sequence durably uploaded, tracked when durable-ack opt-in is enabled
151+
// and the server emits STATUS_DURABLE_ACK frames.
152+
private long highestDurableSequence = -1;
153+
// Opt-in: request server-side STATUS_DURABLE_ACK frames after WAL reaches object store.
154+
// Must be set before the first send; has no effect once the WebSocket upgrade has completed.
155+
private boolean requestDurableAck;
150156
private boolean sawBinaryAck;
157+
private boolean sawPong;
151158
private WebSocketSendQueue sendQueue;
152159

153160
private QwpWebSocketSender(
@@ -290,6 +297,33 @@ public static QwpWebSocketSender connect(
290297
return sender;
291298
}
292299

300+
public static QwpWebSocketSender connect(
301+
String host,
302+
int port,
303+
ClientTlsConfiguration tlsConfig,
304+
int autoFlushRows,
305+
int autoFlushBytes,
306+
long autoFlushIntervalNanos,
307+
int inFlightWindowSize,
308+
String authorizationHeader,
309+
int maxSchemasPerConnection,
310+
boolean requestDurableAck
311+
) {
312+
QwpWebSocketSender sender = new QwpWebSocketSender(
313+
host, port, tlsConfig,
314+
autoFlushRows, autoFlushBytes, autoFlushIntervalNanos,
315+
inFlightWindowSize, authorizationHeader, maxSchemasPerConnection
316+
);
317+
try {
318+
sender.setRequestDurableAck(requestDurableAck);
319+
sender.ensureConnected();
320+
} catch (Throwable t) {
321+
sender.close();
322+
throw t;
323+
}
324+
return sender;
325+
}
326+
293327
/**
294328
* Creates a sender without connecting. For testing only.
295329
* <p>
@@ -302,10 +336,14 @@ public static QwpWebSocketSender connect(
302336
* @return unconnected sender
303337
*/
304338
public static QwpWebSocketSender createForTesting(String host, int port, int inFlightWindowSize) {
339+
return createForTesting(host, port, inFlightWindowSize, null);
340+
}
341+
342+
public static QwpWebSocketSender createForTesting(String host, int port, int inFlightWindowSize, String authorizationHeader) {
305343
return new QwpWebSocketSender(
306344
host, port, null,
307345
DEFAULT_AUTO_FLUSH_ROWS, DEFAULT_AUTO_FLUSH_BYTES, DEFAULT_AUTO_FLUSH_INTERVAL_NANOS,
308-
inFlightWindowSize, null, DEFAULT_MAX_SCHEMAS_PER_CONNECTION
346+
inFlightWindowSize, authorizationHeader, DEFAULT_MAX_SCHEMAS_PER_CONNECTION
309347
);
310348
}
311349

@@ -779,6 +817,27 @@ public int getAutoFlushRows() {
779817
return autoFlushRows;
780818
}
781819

820+
/**
821+
* Returns the highest client batch sequence acknowledged by the server
822+
* (committed to WAL), or -1 if no ACK has been received yet.
823+
*/
824+
public long getHighestAckedSequence() {
825+
return inFlightWindow != null ? inFlightWindow.getHighestAckedSequence() : -1;
826+
}
827+
828+
/**
829+
* Returns the highest client batch sequence that the server has reported
830+
* as durably persisted to the object store via a STATUS_DURABLE_ACK frame,
831+
* or -1 if no durable ACK has been observed yet on this connection.
832+
* <p>
833+
* Only meaningful when the connection was opened with
834+
* {@link #setRequestDurableAck(boolean)} = true on a server where primary
835+
* replication is enabled; otherwise remains -1.
836+
*/
837+
public long getHighestDurableSequence() {
838+
return sendQueue != null ? sendQueue.getHighestDurableSequence() : highestDurableSequence;
839+
}
840+
782841
/**
783842
* Returns the max symbol ID sent to the server.
784843
* Once sent over TCP, server is guaranteed to receive it (or connection dies).
@@ -960,6 +1019,27 @@ public QwpWebSocketSender longColumn(CharSequence columnName, long value) {
9601019
return this;
9611020
}
9621021

1022+
/**
1023+
* Sends a WebSocket PING and reads frames until the PONG comes back,
1024+
* processing any STATUS_DURABLE_ACK or STATUS_OK frames along the way.
1025+
* After this method returns, {@link #getHighestDurableSequence()} reflects
1026+
* the latest durable watermark reported by the server.
1027+
* <p>
1028+
* In async mode the PING is queued for the I/O thread; this method
1029+
* returns once the I/O thread has sent it and processed the response.
1030+
*
1031+
* @throws LineSenderException if the connection is closed or the ping times out
1032+
*/
1033+
public void ping() {
1034+
checkNotClosed();
1035+
ensureConnected();
1036+
if (inFlightWindowSize > 1) {
1037+
sendQueue.pingAndDrain();
1038+
} else {
1039+
syncPing();
1040+
}
1041+
}
1042+
9631043
@Override
9641044
public void reset() {
9651045
checkNotClosed();
@@ -988,6 +1068,27 @@ public void setGorillaEnabled(boolean enabled) {
9881068
this.encoder.setGorillaEnabled(enabled);
9891069
}
9901070

1071+
/**
1072+
* Opts the connection in for STATUS_DURABLE_ACK frames. Must be called
1073+
* before any send operation — the flag is consulted once, during WebSocket
1074+
* upgrade. Setting this true on a server without primary replication
1075+
* enabled is a no-op: the server silently ignores the header.
1076+
* <p>
1077+
* Observe durable progress via {@link #getHighestDurableSequence()}.
1078+
*
1079+
* @throws LineSenderException if the connection is already established or closed
1080+
*/
1081+
public void setRequestDurableAck(boolean enabled) {
1082+
if (closed) {
1083+
throw new LineSenderException("Sender is closed");
1084+
}
1085+
if (connected) {
1086+
throw new LineSenderException(
1087+
"setRequestDurableAck must be called before the first send");
1088+
}
1089+
this.requestDurableAck = enabled;
1090+
}
1091+
9911092
/**
9921093
* Adds a SHORT column value to the current row.
9931094
*
@@ -1215,6 +1316,7 @@ private void ensureConnected() {
12151316
try {
12161317
client.setQwpMaxVersion(QwpConstants.MAX_SUPPORTED_VERSION);
12171318
client.setQwpClientId(QwpConstants.CLIENT_ID);
1319+
client.setQwpRequestDurableAck(requestDurableAck);
12181320
client.connect(host, port);
12191321
client.upgrade(WRITE_PATH, authorizationHeader);
12201322
} catch (Exception e) {
@@ -1639,6 +1741,32 @@ private boolean shouldAutoFlush() {
16391741
return false;
16401742
}
16411743

1744+
private void syncPing() {
1745+
client.sendPing(1000);
1746+
long deadline = System.currentTimeMillis() + InFlightWindow.DEFAULT_TIMEOUT_MS;
1747+
while (System.currentTimeMillis() < deadline) {
1748+
sawPong = false;
1749+
sawBinaryAck = false;
1750+
boolean received = client.receiveFrame(ackHandler, 1000);
1751+
if (received) {
1752+
if (sawBinaryAck) {
1753+
long sequence = ackResponse.getSequence();
1754+
if (ackResponse.isDurableAck()) {
1755+
if (sequence > highestDurableSequence) {
1756+
highestDurableSequence = sequence;
1757+
}
1758+
} else if (ackResponse.isSuccess()) {
1759+
inFlightWindow.acknowledgeUpTo(sequence);
1760+
}
1761+
}
1762+
if (sawPong) {
1763+
return;
1764+
}
1765+
}
1766+
}
1767+
throw new LineSenderException("Ping timed out");
1768+
}
1769+
16421770
private long toMicros(long value, ChronoUnit unit) {
16431771
switch (unit) {
16441772
case NANOS:
@@ -1696,6 +1824,13 @@ private void waitForAck(long expectedSequence) {
16961824
return; // Our batch was acknowledged (cumulative)
16971825
}
16981826
// Got ACK for lower sequence - continue waiting
1827+
} else if (ackResponse.isDurableAck()) {
1828+
// Durable-upload watermark for opted-in connections. Record the highest
1829+
// value seen; this method does not block for durable acks — callers who
1830+
// need to wait on durability can poll getHighestDurableSequence().
1831+
if (sequence > highestDurableSequence) {
1832+
highestDurableSequence = sequence;
1833+
}
16991834
} else {
17001835
String errorMessage = ackResponse.getErrorMessage();
17011836
LineSenderException error = new LineSenderException(
@@ -1746,5 +1881,10 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
17461881
public void onClose(int code, String reason) {
17471882
throw new LineSenderException("WebSocket closed while waiting for ACK: " + reason);
17481883
}
1884+
1885+
@Override
1886+
public void onPong(long payloadPtr, int payloadLen) {
1887+
sender.sawPong = true;
1888+
}
17491889
}
17501890
}

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

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,17 @@ public class WebSocketResponse {
5858
public static final int MIN_ERROR_RESPONSE_SIZE = 11; // status + sequence + error length
5959
// Minimum response size: status (1) + sequence (8)
6060
public static final int MIN_RESPONSE_SIZE = 9;
61-
public static final byte STATUS_INTERNAL_ERROR = 0x06;
6261
// Status codes (must match QWP_SPECIFICATION.md)
6362
public static final byte STATUS_OK = 0x00;
64-
public static final byte STATUS_PARSE_ERROR = 0x05;
63+
/**
64+
* Cumulative durable-upload acknowledgment. Emitted by servers where
65+
* primary replication is enabled and the connection opted in via
66+
* X-QWP-Request-Durable-Ack. Payload layout matches STATUS_OK.
67+
*/
68+
public static final byte STATUS_DURABLE_ACK = 0x02;
6569
public static final byte STATUS_SCHEMA_MISMATCH = 0x03;
70+
public static final byte STATUS_PARSE_ERROR = 0x05;
71+
public static final byte STATUS_INTERNAL_ERROR = 0x06;
6672
public static final byte STATUS_SECURITY_ERROR = 0x08;
6773
public static final byte STATUS_WRITE_ERROR = 0x09;
6874
private String errorMessage;
@@ -75,6 +81,16 @@ public WebSocketResponse() {
7581
this.errorMessage = null;
7682
}
7783

84+
/**
85+
* Creates a durable-upload ACK response.
86+
*/
87+
public static WebSocketResponse durableAck(long sequence) {
88+
WebSocketResponse response = new WebSocketResponse();
89+
response.status = STATUS_DURABLE_ACK;
90+
response.sequence = sequence;
91+
return response;
92+
}
93+
7894
/**
7995
* Creates an error response.
8096
*/
@@ -105,7 +121,7 @@ public static boolean isStructurallyValid(long ptr, int length) {
105121
}
106122

107123
byte status = Unsafe.getUnsafe().getByte(ptr);
108-
if (status == STATUS_OK) {
124+
if (status == STATUS_OK || status == STATUS_DURABLE_ACK) {
109125
return length == MIN_RESPONSE_SIZE;
110126
}
111127

@@ -141,13 +157,22 @@ public long getSequence() {
141157
return sequence;
142158
}
143159

160+
/**
161+
* Returns the raw status byte.
162+
*/
163+
public byte getStatus() {
164+
return status;
165+
}
166+
144167
/**
145168
* Returns a human-readable status name.
146169
*/
147170
public String getStatusName() {
148171
switch (status) {
149172
case STATUS_OK:
150173
return "OK";
174+
case STATUS_DURABLE_ACK:
175+
return "DURABLE_ACK";
151176
case STATUS_PARSE_ERROR:
152177
return "PARSE_ERROR";
153178
case STATUS_SCHEMA_MISMATCH:
@@ -164,7 +189,14 @@ public String getStatusName() {
164189
}
165190

166191
/**
167-
* Returns true if this is a success response.
192+
* Returns true when this is a cumulative durable-upload ACK (STATUS_DURABLE_ACK).
193+
*/
194+
public boolean isDurableAck() {
195+
return status == STATUS_DURABLE_ACK;
196+
}
197+
198+
/**
199+
* Returns true if this is a success response (STATUS_OK).
168200
*/
169201
public boolean isSuccess() {
170202
return status == STATUS_OK;
@@ -192,8 +224,8 @@ public boolean readFrom(long ptr, int length) {
192224
sequence = Unsafe.getUnsafe().getLong(ptr + offset);
193225
offset += 8;
194226

195-
// Error message (if status != OK and more data available)
196-
if (status != STATUS_OK && length >= offset + 2) {
227+
// Error message (present only for error frames; STATUS_OK and STATUS_DURABLE_ACK carry no message)
228+
if (status != STATUS_OK && status != STATUS_DURABLE_ACK && length >= offset + 2) {
197229
int msgLen = Unsafe.getUnsafe().getShort(ptr + offset) & 0xFFFF;
198230
offset += 2;
199231

0 commit comments

Comments
 (0)