Skip to content

Commit 198cb77

Browse files
bluestreak01claude
andcommitted
Latch terminal failures on QwpQueryClient
Transport- or protocol-level faults detected by the egress I/O thread (server close, truncated/unknown frames, decode failures, send/recv exceptions) now latch a sticky terminal state on the client. The next QwpQueryClient.execute() short-circuits via handler.onError with the stored status and message instead of dispatching another query to the broken connection. QwpEgressIoThread gains a TerminalFailureListener that the client wires to a first-failure-wins CAS (AtomicReference). The emitTerminalError helper calls the listener before offering the per-query QueryEvent so the sender-thread latch is visible before the user's handler reacts. Per-query QUERY_ERROR responses are explicitly NOT terminal: the connection stays healthy and the next execute() runs normally. The same applies to user-driven shutdown paths, which leave the latch clear so close() followed by connect() on a new client starts fresh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 77bbeba commit 198cb77

2 files changed

Lines changed: 97 additions & 8 deletions

File tree

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

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler {
8181
// Single-slot request queue (Phase-1 allows one in-flight query).
8282
private final BlockingQueue<QueryRequest> requests = new ArrayBlockingQueue<>(1);
8383
private final NativeBufferWriter sendScratch = new NativeBufferWriter();
84+
// Notified once when the I/O thread detects a transport- or protocol-level
85+
// terminal failure (onClose, truncated/unknown frames, send/receive
86+
// throwing). Distinct from per-query QUERY_ERROR, which leaves the
87+
// connection healthy and is delivered only through the events queue.
88+
private final TerminalFailureListener terminalFailureListener;
8489
private final WebSocketClient wsClient;
8590
// Per-query credit state (accessed only from the I/O thread).
8691
// creditEnabled == (initialCredit > 0); controls whether we emit CREDIT
@@ -98,7 +103,12 @@ public class QwpEgressIoThread implements Runnable, WebSocketFrameHandler {
98103
private volatile boolean shutdown;
99104

100105
public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize) {
106+
this(wsClient, bufferPoolSize, null);
107+
}
108+
109+
public QwpEgressIoThread(WebSocketClient wsClient, int bufferPoolSize, TerminalFailureListener terminalFailureListener) {
101110
this.wsClient = wsClient;
111+
this.terminalFailureListener = terminalFailureListener;
102112
this.freeBuffers = new ArrayBlockingQueue<>(bufferPoolSize);
103113
// +2 reserves slots for a trailing RESULT_END and a synthetic error
104114
// frame the I/O thread may emit on shutdown, so a full buffer pool
@@ -153,7 +163,7 @@ public static QueryEvent decodeError(long payload, int payloadLen) {
153163
@Override
154164
public void onBinaryMessage(long payloadPtr, int payloadLen) {
155165
if (payloadLen < QwpConstants.HEADER_SIZE + 1) {
156-
emitError((byte) 0, "server sent truncated frame");
166+
emitTerminalError((byte) 0, "server sent truncated frame");
157167
// Stop the receive loop; the framing is broken and any further bytes
158168
// would be misinterpreted relative to the expected message boundary.
159169
currentQueryDone = true;
@@ -173,13 +183,14 @@ public void onBinaryMessage(long payloadPtr, int payloadLen) {
173183
decodeAndEmitError(payloadPtr, payloadLen);
174184
currentQueryDone = true;
175185
} else {
176-
emitError((byte) 0, "unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF));
186+
emitTerminalError((byte) 0, "unknown msg_kind 0x" + Integer.toHexString(msgKind & 0xFF));
177187
currentQueryDone = true;
178188
}
179189
}
180190

181191
@Override
182192
public void onClose(int code, String reason) {
193+
notifyTerminalFailure((byte) 0, "server closed connection: code=" + code + " reason=" + reason);
183194
if (!currentQueryDone) {
184195
emitError((byte) 0, "server closed connection: code=" + code + " reason=" + reason);
185196
currentQueryDone = true;
@@ -244,16 +255,22 @@ public void run() {
244255
}
245256
}
246257
} catch (Throwable t) {
258+
notifyTerminalFailure((byte) 0, "I/O thread failure: " + t.getMessage());
247259
emitErrorBlocking((byte) 0, "I/O thread failure: " + t.getMessage());
248260
} finally {
249261
// Wake any user thread blocked on events.take(). Without this, a close()
250262
// (or any abnormal exit) while a user thread is mid-execute() would let
251263
// takeEvent() block forever -- once the I/O thread is gone, no further
252264
// events arrive on the queue.
253265
if (!currentQueryDone) {
254-
emitErrorBlocking((byte) 0, shutdown
266+
String msg = shutdown
255267
? "I/O thread shut down with query in flight"
256-
: "I/O thread terminated with query in flight");
268+
: "I/O thread terminated with query in flight";
269+
if (!shutdown) {
270+
// Shutdown is a user-driven close; don't mark the client terminal for it.
271+
notifyTerminalFailure((byte) 0, msg);
272+
}
273+
emitErrorBlocking((byte) 0, msg);
257274
currentQueryDone = true;
258275
}
259276
}
@@ -297,7 +314,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
297314
long p = payload + QwpConstants.HEADER_SIZE + 1 + 8;
298315
long limit = payload + payloadLen;
299316
if (p + 1 > limit) {
300-
emitError((byte) 0, "EXEC_DONE frame truncated before op_type");
317+
emitTerminalError((byte) 0, "EXEC_DONE frame truncated before op_type");
301318
return;
302319
}
303320
byte opType = Unsafe.getUnsafe().getByte(p++);
@@ -309,7 +326,7 @@ private void decodeAndEmitExecDone(long payload, int payloadLen) {
309326
if ((b & 0x80) == 0) break;
310327
shift += 7;
311328
if (shift > 63) {
312-
emitError((byte) 0, "EXEC_DONE rows_affected varint overflow");
329+
emitTerminalError((byte) 0, "EXEC_DONE rows_affected varint overflow");
313330
return;
314331
}
315332
}
@@ -390,6 +407,15 @@ private void emitErrorBlocking(byte status, String message) {
390407
}
391408
}
392409

410+
// Emits a per-query error event AND latches the client terminal-failure
411+
// state. Use for transport- or protocol-level faults where the WebSocket
412+
// is no longer usable; per-query QUERY_ERROR must still go through
413+
// plain emitError, since the connection remains healthy after it.
414+
private void emitTerminalError(byte status, String message) {
415+
notifyTerminalFailure(status, message);
416+
emitError(status, message);
417+
}
418+
393419
private void handleResultBatch(long payloadPtr, int payloadLen) {
394420
QwpBatchBuffer buf;
395421
try {
@@ -413,7 +439,9 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
413439
decoder.decode(buf, payloadPtr, payloadLen);
414440
} catch (QwpDecodeException e) {
415441
freeBuffers.offer(buf);
416-
emitError((byte) 0, "decode failure: " + e.getMessage());
442+
// A decode failure leaves the client-side decoder out of step with
443+
// the server's byte stream: the next frame cannot be trusted.
444+
emitTerminalError((byte) 0, "decode failure: " + e.getMessage());
417445
currentQueryDone = true;
418446
return;
419447
}
@@ -437,6 +465,17 @@ private void handleResultBatch(long payloadPtr, int payloadLen) {
437465
}
438466
}
439467

468+
private void notifyTerminalFailure(byte status, String message) {
469+
if (terminalFailureListener != null) {
470+
try {
471+
terminalFailureListener.onTerminalFailure(status, message);
472+
} catch (Throwable ignored) {
473+
// Listener must not bring down the I/O thread. A first-failure-wins
474+
// CAS in the listener cannot throw in practice; defensive anyway.
475+
}
476+
}
477+
}
478+
440479
/**
441480
* Builds and transmits a CANCEL frame on the WebSocket. Wire format:
442481
* {@code msg_kind(0x14) + request_id(u64)}.
@@ -517,6 +556,11 @@ void closePool() {
517556
events.offer(new QueryEvent().asError((byte) 0, "QwpQueryClient closed"));
518557
}
519558

559+
@FunctionalInterface
560+
public interface TerminalFailureListener {
561+
void onTerminalFailure(byte status, String message);
562+
}
563+
520564
private static final class QueryRequest {
521565
final long initialCredit;
522566
final long requestId;

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

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
import java.nio.charset.StandardCharsets;
4040
import java.util.Base64;
41+
import java.util.concurrent.atomic.AtomicReference;
4142

4243
/**
4344
* QWP egress (query results) client.
@@ -51,6 +52,15 @@
5152
* Thread safety: not thread-safe for concurrent queries on the same client.
5253
* One {@link #execute} at a time. Opening one client per query-issuing thread
5354
* is the recommended pattern.
55+
* <p>
56+
* Terminal-failure model: transport- or protocol-level faults detected by the
57+
* I/O thread (server close, truncated/unknown frames, send/recv exceptions)
58+
* latch a sticky terminal failure on the client. Subsequent {@link #execute}
59+
* calls short-circuit via {@link QwpColumnBatchHandler#onError} with the
60+
* stored status/message rather than dispatching to the now-broken connection.
61+
* Per-query {@code QUERY_ERROR} responses are NOT terminal -- the connection
62+
* remains usable for the next query. To retry after a terminal failure the
63+
* caller must {@link #close} this client and open a fresh one.
5464
*/
5565
public class QwpQueryClient implements QuietCloseable {
5666

@@ -117,6 +127,11 @@ public class QwpQueryClient implements QuietCloseable {
117127
// hit the timeout branch in under a second instead of spending five.
118128
@SuppressWarnings("FieldMayBeFinal")
119129
private volatile long shutdownJoinMs = 5_000;
130+
// Latched when the I/O thread reports a transport- or protocol-level
131+
// failure (see QwpEgressIoThread notifyTerminalFailure callers). Checked
132+
// on every user-facing method so a broken connection short-circuits
133+
// instead of submitting work that will only collect an error event.
134+
private final AtomicReference<TerminalFailure> terminalFailure = new AtomicReference<>();
120135
private boolean tlsEnabled;
121136
// Only meaningful when tlsEnabled. Default is full validation against the JVM's trust store.
122137
private int tlsValidationMode = ClientTlsConfiguration.TLS_VALIDATION_MODE_FULL;
@@ -478,7 +493,8 @@ public void connect() {
478493
probeZstdAvailable();
479494
}
480495

481-
ioThread = new QwpEgressIoThread(webSocketClient, bufferPoolSize);
496+
terminalFailure.set(null);
497+
ioThread = new QwpEgressIoThread(webSocketClient, bufferPoolSize, this::recordTerminalFailure);
482498
ioThreadHandle = new Thread(ioThread, "qwp-egress-io");
483499
ioThreadHandle.setDaemon(true);
484500
ioThreadHandle.start();
@@ -491,6 +507,11 @@ public void connect() {
491507
* Blocks the calling thread until the server sends {@code RESULT_END} or
492508
* {@code QUERY_ERROR}. While the user thread is inside {@code handler.onBatch},
493509
* the I/O thread keeps reading and decoding ahead up to the configured buffer-pool depth.
510+
* <p>
511+
* Short-circuits when a terminal failure has already been latched by the I/O
512+
* thread (see class Javadoc): {@code handler.onError} fires immediately with
513+
* the stored status and message, and no query is dispatched. The caller must
514+
* {@link #close} the client and open a new one to retry.
494515
*/
495516
public void execute(String sql, QwpColumnBatchHandler handler) {
496517
if (!connected) {
@@ -506,6 +527,16 @@ public void execute(String sql, QwpColumnBatchHandler handler) {
506527
handler.onError((byte) 0, "QwpQueryClient is closed");
507528
return;
508529
}
530+
TerminalFailure tf = terminalFailure.get();
531+
if (tf != null) {
532+
// I/O thread has reported a transport- or protocol-level failure.
533+
// Submitting another query would only accumulate work the I/O thread
534+
// cannot process. Surface the stored failure via the handler
535+
// immediately -- users who want to retry must close() and create a
536+
// fresh client.
537+
handler.onError(tf.status, tf.message);
538+
return;
539+
}
509540
long requestId = nextRequestId++;
510541
currentRequestId = requestId;
511542
try {
@@ -731,6 +762,10 @@ public QwpQueryClient withTrustStore(String trustStorePath, char[] trustStorePas
731762
return this;
732763
}
733764

765+
void recordTerminalFailure(byte status, String message) {
766+
terminalFailure.compareAndSet(null, new TerminalFailure(status, message));
767+
}
768+
734769
private static String defaultClientId() {
735770
return "questdb-java-egress/1.0.0";
736771
}
@@ -789,4 +824,14 @@ private void probeZstdAvailable() {
789824
}
790825
Zstd.freeDCtx(dctx);
791826
}
827+
828+
private static final class TerminalFailure {
829+
final String message;
830+
final byte status;
831+
832+
TerminalFailure(byte status, String message) {
833+
this.status = status;
834+
this.message = message;
835+
}
836+
}
792837
}

0 commit comments

Comments
 (0)