Skip to content

Commit a81d259

Browse files
committed
read failover
1 parent 229ee79 commit a81d259

13 files changed

Lines changed: 2042 additions & 182 deletions

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ public class QueryEvent {
3636
public static final int KIND_END = 1;
3737
public static final int KIND_ERROR = 2;
3838
public static final int KIND_EXEC_DONE = 3;
39+
/**
40+
* Synthesised on the I/O thread when the server closes the socket,
41+
* receiveFrame / decode raises, or the I/O thread abnormally exits.
42+
* Distinct from {@link #KIND_ERROR} (server-emitted {@code QUERY_ERROR})
43+
* so {@code execute()} can decide whether to trigger failover without
44+
* having to reconstruct the classification from a side-channel latch.
45+
*/
46+
public static final int KIND_TRANSPORT_ERROR = 4;
3947

4048
public QwpBatchBuffer buffer; // valid for KIND_BATCH (must be released to pool by consumer)
4149
public byte errorStatus; // valid for KIND_ERROR
@@ -73,4 +81,12 @@ public QueryEvent asExecDone(short opType, long rowsAffected) {
7381
this.rowsAffected = rowsAffected;
7482
return this;
7583
}
84+
85+
public QueryEvent asTransportError(byte status, String message) {
86+
this.kind = KIND_TRANSPORT_ERROR;
87+
this.buffer = null;
88+
this.errorStatus = status;
89+
this.errorMessage = message;
90+
return this;
91+
}
7692
}

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,26 @@ public interface QwpColumnBatchHandler {
6666
*/
6767
void onError(byte status, String message);
6868

69+
/**
70+
* Invoked when {@link QwpQueryClient#execute} has transparently reconnected
71+
* to another endpoint after a transport failure and is about to re-submit
72+
* the query (with {@code failover=on}, the default).
73+
* <p>
74+
* {@code newNode} is the {@link QwpServerInfo} of the endpoint the client
75+
* just bound to, or {@code null} if the new server negotiated the v1
76+
* protocol (no SERVER_INFO frame).
77+
* <p>
78+
* After this callback fires, {@link #onBatch} will be invoked again with
79+
* {@code batch_seq} restarting at 0 on the new connection. Handlers that
80+
* accumulate rows across batches should discard whatever they built up
81+
* from the previous attempt. The default implementation is a no-op, safe
82+
* for handlers that don't care (for example, simple row-count aggregators
83+
* that are idempotent against replays) and for applications that set
84+
* {@code failover=off}.
85+
*/
86+
default void onFailoverReset(QwpServerInfo newNode) {
87+
}
88+
6989
/**
7090
* Invoked in place of {@link #onBatch} + {@link #onEnd} when the query was
7191
* a non-SELECT (DDL, INSERT, UPDATE, etc.). No batches are delivered for

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

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ public static QueryEvent decodeError(long payload, int payloadLen) {
163163
@Override
164164
public void onBinaryMessage(long payloadPtr, int payloadLen) {
165165
if (payloadLen < QwpConstants.HEADER_SIZE + 1) {
166-
emitTerminalError("server sent truncated frame");
166+
emitTerminalTransportError("server sent truncated frame");
167167
// Stop the receive loop; the framing is broken and any further bytes
168168
// would be misinterpreted relative to the expected message boundary.
169169
currentQueryDone = true;
@@ -183,16 +183,17 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
183183
decodeAndEmitError(payloadPtr, payloadLen);
184184
currentQueryDone = true;
185185
} else {
186-
emitTerminalError("unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF));
186+
emitTerminalTransportError("unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF));
187187
currentQueryDone = true;
188188
}
189189
}
190190

191191
@Override
192192
public void onClose(int code, String reason) {
193-
notifyTerminalFailure("server closed connection: code=" + code + " reason=" + reason);
193+
String msg = "server closed connection: code=" + code + " reason=" + reason;
194+
notifyTerminalFailure(msg);
194195
if (!currentQueryDone) {
195-
emitError(WebSocketResponse.STATUS_INTERNAL_ERROR, "server closed connection: code=" + code + " reason=" + reason);
196+
events.offer(new QueryEvent().asTransportError(WebSocketResponse.STATUS_INTERNAL_ERROR, msg));
196197
currentQueryDone = true;
197198
}
198199
}
@@ -261,8 +262,9 @@ public void run() {
261262
}
262263
}
263264
} catch (Throwable t) {
264-
notifyTerminalFailure("I/O thread failure: " + t.getMessage());
265-
emitErrorBlocking("I/O thread failure: " + t.getMessage());
265+
String msg = "I/O thread failure: " + t.getMessage();
266+
notifyTerminalFailure(msg);
267+
emitTransportErrorBlocking(WebSocketResponse.STATUS_INTERNAL_ERROR, msg);
266268
} finally {
267269
// Wake any user thread blocked on events.take(). Without this, a close()
268270
// (or any abnormal exit) while a user thread is mid-execute() would let
@@ -272,11 +274,15 @@ public void run() {
272274
String msg = shutdown
273275
? "I/O thread shut down with query in flight"
274276
: "I/O thread terminated with query in flight";
275-
if (!shutdown) {
276-
// Shutdown is a user-driven close; don't mark the client terminal for it.
277+
if (shutdown) {
278+
// User-driven close. Emit a plain query error (KIND_ERROR): the
279+
// user initiated the shutdown, we do not want failover to fire on
280+
// their behalf.
281+
emitError(WebSocketResponse.STATUS_INTERNAL_ERROR, msg);
282+
} else {
277283
notifyTerminalFailure(msg);
284+
emitTransportErrorBlocking(WebSocketResponse.STATUS_INTERNAL_ERROR, msg);
278285
}
279-
emitErrorBlocking(msg);
280286
currentQueryDone = true;
281287
}
282288
}
@@ -335,7 +341,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
335341
long p = payload + QwpConstants.HEADER_SIZE + 1 + 8;
336342
long limit = payload + payloadLen;
337343
if (p + 1 > limit) {
338-
emitTerminalError("EXEC_DONE frame truncated before op_type");
344+
emitTerminalTransportError("EXEC_DONE frame truncated before op_type");
339345
return;
340346
}
341347
byte opType = Unsafe.getUnsafe().getByte(p++);
@@ -347,7 +353,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
347353
if ((b & 0x80) == 0) break;
348354
shift += 7;
349355
if (shift > 63) {
350-
emitTerminalError("EXEC_DONE rows_affected varint overflow");
356+
emitTerminalTransportError("EXEC_DONE rows_affected varint overflow");
351357
return;
352358
}
353359
}
@@ -408,12 +414,13 @@ private void emitError(byte status, String message) {
408414
}
409415

410416
/**
411-
* Like {@link #emitError} but retries until the event is enqueued. Used on
412-
* shutdown and fatal-error paths where dropping the event would leave the
413-
* user thread blocked on {@link #takeEvent} indefinitely.
417+
* Like {@link #emitError} but emits a {@code KIND_TRANSPORT_ERROR} event
418+
* rather than {@code KIND_ERROR}, and retries until the event is enqueued.
419+
* Used on transport-failure paths where the WebSocket is torn down and
420+
* the user thread needs to be woken so failover can kick in.
414421
*/
415-
private void emitErrorBlocking(String message) {
416-
QueryEvent ev = new QueryEvent().asError(WebSocketResponse.STATUS_INTERNAL_ERROR, message);
422+
private void emitTransportErrorBlocking(byte status, String message) {
423+
QueryEvent ev = new QueryEvent().asTransportError(status, message);
417424
while (!events.offer(ev)) {
418425
// Events queue is bounded; the consumer side (user thread) drains
419426
// steadily under a cooperative shutdown. Yield between retries so
@@ -428,13 +435,20 @@ private void emitErrorBlocking(String message) {
428435
}
429436
}
430437

431-
// Emits a per-query error event AND latches the client terminal-failure
432-
// state. Use for transport- or protocol-level faults where the WebSocket
433-
// is no longer usable; per-query QUERY_ERROR must still go through
434-
// plain emitError, since the connection remains healthy after it.
435-
private void emitTerminalError(String message) {
438+
/**
439+
* Emits a {@code KIND_TRANSPORT_ERROR} event AND latches the client's
440+
* terminal-failure state. Use for transport- or protocol-level faults
441+
* where the WebSocket is no longer usable (server close, send/recv
442+
* exception, decoder out of sync). Per-query {@code QUERY_ERROR} still
443+
* goes through {@link #emitError}, since the connection remains healthy
444+
* after one. The event-kind split means {@code execute()} classifies
445+
* the failure by the event kind, not by peeking at the terminal-failure
446+
* latch -- the latch stays strictly for short-circuiting subsequent
447+
* {@code execute()} calls on a broken client.
448+
*/
449+
private void emitTerminalTransportError(String message) {
436450
notifyTerminalFailure(message);
437-
emitError(WebSocketResponse.STATUS_INTERNAL_ERROR, message);
451+
events.offer(new QueryEvent().asTransportError(WebSocketResponse.STATUS_INTERNAL_ERROR, message));
438452
}
439453

440454
private void handleResultBatch(long payloadPtr, int payloadLen) {
@@ -467,7 +481,7 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
467481
}
468482
// A decode failure leaves the client-side decoder out of step with
469483
// the server's byte stream: the next frame cannot be trusted.
470-
emitTerminalError("decode failure: " + e.getMessage());
484+
emitTerminalTransportError("decode failure: " + e.getMessage());
471485
currentQueryDone = true;
472486
return;
473487
}

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

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,34 @@ public final class QwpEgressMsgKind {
4141
public static final byte QUERY_REQUEST = 0x10;
4242
public static final byte RESULT_BATCH = 0x11;
4343
public static final byte RESULT_END = 0x12;
44-
45-
// Egress-specific QUERY_ERROR status codes. Extend the ingress
46-
// QwpConstants.STATUS_* namespace (0x00-0x09).
44+
/**
45+
* Role value on {@code SERVER_INFO.role}: the authoritative write node.
46+
*/
47+
public static final byte ROLE_PRIMARY = 1;
48+
/**
49+
* Role value on {@code SERVER_INFO.role}: promotion-in-progress. Clients
50+
* insisting on primary-only reads may still route here; clients needing
51+
* write-visible reads should wait for {@link #ROLE_PRIMARY}.
52+
*/
53+
public static final byte ROLE_PRIMARY_CATCHUP = 3;
54+
/**
55+
* Role value on {@code SERVER_INFO.role}: the node is a read-only replica
56+
* that pulls WAL segments from the shared object store. Reads may lag the
57+
* primary by the replication poll interval plus transport time.
58+
*/
59+
public static final byte ROLE_REPLICA = 2;
60+
/**
61+
* Role value on {@code SERVER_INFO.role}: no replication is configured. The
62+
* standalone OSS default; behaves like a primary for routing purposes.
63+
*/
64+
public static final byte ROLE_STANDALONE = 0;
65+
/**
66+
* Server -> client. Unsolicited frame delivered as the first QWP message
67+
* on every v2 WebSocket connection. Body (little-endian): {@code
68+
* msg_kind:u8, role:u8, epoch:u64, capabilities:u32, server_wall_ns:i64,
69+
* cluster_id:u16_len+utf8, node_id:u16_len+utf8}.
70+
*/
71+
public static final byte SERVER_INFO = 0x17;
4772
/**
4873
* Status byte on a {@code QUERY_ERROR} frame: the query was cancelled,
4974
* either by a client {@code CANCEL} frame or by explicit server-side cancel.

0 commit comments

Comments
 (0)